28 KiB
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
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 toAddress(notLocation) 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:
{
"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 session404 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 (
cutIdnot 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:
- Database bounds filter — Fast WHERE clause on lat/lng
- 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 addressesstartLatitude(number, optional): Starting position latitudestartLongitude(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 enumsupportLevel(optional): Support level identified during visitsignRequested(optional, default: false): Lawn sign requestedsignSize(optional): Sign size if requestednotes(optional): Visit notesdurationSeconds(optional): Time spent at doorsessionId(optional): Active canvass session IDshiftId(optional): Associated shift IDupdateLocation(optional, default: true): Update address record with visit 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_totalcounter 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_HOMEREFUSEDMOVED
Logic:
- Find all addresses at location (building)
- Filter to unvisited addresses (no existing visit records)
- Create visit records for all unvisited addresses in bulk
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:
supportLevelsignsignSizenotes
Admins Only (SUPER_ADMIN, MAP_ADMIN):
firstNamelastNameaddressunitNumberemailphone
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 numberlimit(default: 20, max: 100): Results per pagecutId(optional): Filter by cutuserId(optional): Filter by volunteeroutcome(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.completionPercentagefield
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=falseto see all addresses - Verify addresses have visits recorded (check
lastVisitfield)
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 - 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