277 lines
6.9 KiB
Markdown

# 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](../schema.md#canvassing) for complete field listings.
---
## Session Lifecycle
```mermaid
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
```prisma
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`
```typescript
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:**
```mermaid
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
```typescript
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
```typescript
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
```typescript
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
```typescript
await prisma.canvassSession.update({
where: { id: sessionId },
data: {
status: CanvassSessionStatus.COMPLETED,
endedAt: new Date(),
trackingSession: {
update: {
isActive: false,
endedAt: new Date(),
},
},
},
});
```
### Get Walking Route
```typescript
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](../schema.md#canvassing) Complete field listings
- [Database Overview](../index.md) ER diagram
- [API Canvass Routes](../../api/canvass.md) REST endpoints
- [Volunteer Canvass Map](../../admin/volunteer-map.md) Full-screen canvass UI
- [Admin Canvass Dashboard](../../admin/canvass-dashboard.md) Admin oversight UI