277 lines
6.9 KiB
Markdown
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
|