6.9 KiB
6.9 KiB
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 progressCOMPLETED— Session ended by userABANDONED— 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 tonullor1 - Outcome:
NOT_HOME→ No support level
Walking Route Algorithm
Algorithm: Nearest-neighbor with haversine distance calculation
Steps:
- Get all unvisited addresses in cut
- Start from session start coordinates (or cut centroid)
- Find nearest unvisited address (haversine distance)
- Add to route, mark as visited
- 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
isActiveflag 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