# 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 | 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 | ## Database Models ### CanvassSession ```prisma 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 ```prisma 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) ```prisma 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) | 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) | ### Admin Endpoints (Authentication Required, MAP_ADMIN Roles) | 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` --- ## Volunteer Endpoint Details ### POST /api/map/canvass/sessions Start new canvass session for a cut. **Request Body:** ```json { "cutId": "clxCut123", "shiftId": "clxShift456", "startLatitude": 43.6532, "startLongitude": -79.3832 } ``` **Response (201 Created):** ```json { "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):** ```json [ { "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):** ```json [ { "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. **Polygon filter** — In-memory point-in-polygon check **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):** ```json { "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: ```typescript // 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:** ```json { "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`: ```typescript 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:** ```json { "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. Filter to unvisited addresses (no existing visit records) 3. Create visit records for all unvisited addresses in bulk **Response (201 Created):** ```json { "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):** ```json { "supportLevel": "LEVEL_2", "sign": true, "signSize": "Large", "notes": "Willing to volunteer" } ``` **Request Body (Admin):** ```json { "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:** ```typescript 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):** ```json { "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):** ```json { "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):** ```json [ { "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:** ```typescript // 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:** ```typescript // 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:** ```typescript 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:** ```typescript 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:** ```typescript recordCanvassVisit(data.outcome); // Prometheus counter ``` --- ### canvassService.getWalkingRoute(cutId, userId, options) Get optimized walking route for cut. **Algorithm:** ```typescript 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: ```typescript // 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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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:** ```typescript // 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 --- ## Related Documentation - [Shifts Module](/v2/backend/modules/shifts.md) - Shift CRUD + signup system - [Cuts Module](/v2/backend/modules/cuts.md) - Polygon filtering - [Locations Module](/v2/backend/modules/locations.md) - Location management - [Spatial Utils](/v2/backend/utilities/spatial-utils.md) - Point-in-polygon, haversine distance - [Frontend: VolunteerMapPage](/v2/frontend/pages/volunteer/volunteer-map-page.md) - Canvassing map UI - [Frontend: CanvassDashboardPage](/v2/frontend/pages/admin/canvass-dashboard-page.md) - Admin dashboard - [API Reference: Canvass](/v2/api-reference/canvass.md) - Complete endpoint reference - [Feature: Volunteer Canvassing](/v2/features/map/volunteer-canvassing.md) - Canvassing feature guide