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
);
Related Documentation¶
- Schema Reference — Complete field listings
- Database Overview — ER diagram
- API Canvass Routes — REST endpoints
- Volunteer Canvass Map — Full-screen canvass UI
- Admin Canvass Dashboard — Admin oversight UI