1021 lines
28 KiB
Markdown
1021 lines
28 KiB
Markdown
# Canvassing Session System
|
|
|
|
## Overview
|
|
|
|
The canvassing system provides a complete door-to-door organizing workflow with GPS tracking, walking route optimization, visit recording, and progress tracking. It enables volunteers to efficiently canvass assigned territories using mobile devices with real-time location updates.
|
|
|
|
**Key Capabilities:**
|
|
|
|
- **Session Lifecycle**: ACTIVE → COMPLETED → ABANDONED (auto-close after 12h)
|
|
- **Walking Route Algorithm**: Nearest-neighbor optimization from volunteer GPS position
|
|
- **Visit Recording**: 7 outcome types with support level updates
|
|
- **GPS Integration**: Live tracking via TrackingSession (1:1 relationship)
|
|
- **Rate Limiting**: 30 visits/min per IP to prevent abuse
|
|
- **Progress Tracking**: Cut completion percentage auto-calculated
|
|
- **Admin Oversight**: Active sessions dashboard, activity feed, leaderboard
|
|
- **Volunteer Portal**: Full-screen map with bottom-sheet visit recording
|
|
|
|
**Use Cases:**
|
|
|
|
- Door-to-door canvassing for electoral campaigns
|
|
- Voter ID (identifying supporter levels)
|
|
- GOTV (Get Out The Vote) efforts
|
|
- Sign placement tracking
|
|
- Petition signature collection
|
|
- Issue surveys
|
|
- Volunteer coordination
|
|
|
|
## Architecture
|
|
|
|
```mermaid
|
|
graph TD
|
|
A[Volunteer] -->|Start Session| B[VolunteerMapPage]
|
|
B -->|POST /api/map/canvass/sessions| C[Canvass Service]
|
|
C -->|Create| D[(CanvassSession)]
|
|
C -->|Start GPS| E[Tracking Service]
|
|
E -->|Create| F[(TrackingSession)]
|
|
|
|
B -->|Load Addresses| C
|
|
C -->|Filter by Cut| G[Spatial Utils]
|
|
G -->|Point-in-Polygon| H[(Location Model)]
|
|
|
|
B -->|Calculate Route| I[Walking Route Service]
|
|
I -->|Nearest Neighbor| J[Haversine Distance]
|
|
J -->|Return Route| B
|
|
|
|
K[Volunteer GPS] -->|Submit Points| E
|
|
E -->|Save| L[(TrackPoint)]
|
|
E -->|Calculate Distance| J
|
|
|
|
B -->|Record Visit| C
|
|
C -->|Create| M[(CanvassVisit)]
|
|
C -->|Update Address| N[(Address Model)]
|
|
C -->|Update Progress| D
|
|
|
|
O[Admin] -->|View Dashboard| P[CanvassDashboardPage]
|
|
P -->|GET /api/map/canvass/admin/activity| C
|
|
C -->|Aggregate Stats| D
|
|
C -->|Activity Feed| M
|
|
|
|
D -->|1:1| F
|
|
D -->|1:N| M
|
|
M -->|N:1| N
|
|
|
|
style D fill:#e1f5ff
|
|
style F fill:#e1f5ff
|
|
style M fill:#e1f5ff
|
|
style N fill:#e1f5ff
|
|
style H fill:#e1f5ff
|
|
```
|
|
|
|
**Flow Description:**
|
|
|
|
1. **Volunteer starts session** → Creates CanvassSession + TrackingSession, loads addresses within cut
|
|
2. **Calculate route** → Walking route service uses nearest-neighbor from volunteer GPS position
|
|
3. **GPS tracking** → Auto-submit points every 10s, calculate distance with haversine
|
|
4. **Record visit** → Create CanvassVisit with outcome, update Address support level, update session progress
|
|
5. **End session** → Mark session COMPLETED, end tracking session, calculate final stats
|
|
6. **Admin oversight** → View active sessions, activity feed, cut progress, volunteer leaderboard
|
|
|
|
## Database Models
|
|
|
|
### CanvassSession Model
|
|
|
|
See [CanvassSession Model Documentation](../../database/models/canvass.md#canvasssession-model) for full schema.
|
|
|
|
**Key Fields:**
|
|
|
|
- `userId`: Foreign key to volunteer User
|
|
- `cutId`: Foreign key to Cut (territory)
|
|
- `shiftId`: Optional foreign key to Shift (if started from shift)
|
|
- `status`: ACTIVE | COMPLETED | ABANDONED
|
|
- `startedAt`: Session start timestamp
|
|
- `endedAt`: Session end timestamp (null while active)
|
|
- `totalVisits`: Count of CanvassVisit records
|
|
- `completionPercentage`: Auto-calculated from cut progress
|
|
|
|
**Status Lifecycle:**
|
|
|
|
```
|
|
ACTIVE (session running)
|
|
↓ (volunteer ends session)
|
|
COMPLETED
|
|
|
|
OR
|
|
|
|
ACTIVE (session running > 12 hours)
|
|
↓ (auto-cleanup cron)
|
|
ABANDONED
|
|
```
|
|
|
|
### CanvassVisit Model
|
|
|
|
See [CanvassVisit Model Documentation](../../database/models/canvass.md#canvassvisit-model) for full schema.
|
|
|
|
**Key Fields:**
|
|
|
|
- `sessionId`: Foreign key to CanvassSession
|
|
- `userId`: Foreign key to volunteer User
|
|
- `addressId`: Foreign key to Address (specific unit visited)
|
|
- `outcome`: Visit result (7 types)
|
|
- `supportLevel`: Updated support level (LEVEL_1-4 or null)
|
|
- `signRequested`: Boolean - resident wants lawn/window sign
|
|
- `notes`: Free-text canvass notes
|
|
- `visitedAt`: Visit timestamp
|
|
- `durationSeconds`: Time spent at door (auto-calculated)
|
|
|
|
**Visit Outcome Enum:**
|
|
|
|
```typescript
|
|
enum VisitOutcome {
|
|
NOT_HOME // Nobody answered door
|
|
REFUSED // Refused to speak
|
|
MOVED // Resident moved away
|
|
ALREADY_VOTED // Already voted (GOTV)
|
|
SPOKE_WITH // Had conversation
|
|
LEFT_LITERATURE // Left campaign material
|
|
COME_BACK_LATER // Asked to return later
|
|
}
|
|
```
|
|
|
|
**Related Models:**
|
|
|
|
- [TrackingSession](../../database/models/canvass.md#trackingsession-model) — GPS tracking (1:1)
|
|
- [Address](../../database/models/map.md#address-model) — Updated with visit data
|
|
- [Cut](../../database/models/map.md#cut-model) — Territory boundary
|
|
- [Shift](../../database/models/map.md#shift-model) — Optional shift assignment
|
|
|
|
## API Endpoints
|
|
|
|
See [Canvass Backend Module Documentation](../../backend/modules/map/canvass.md) for full API reference.
|
|
|
|
**Volunteer Endpoints:**
|
|
|
|
| Method | Endpoint | Auth | Description |
|
|
|--------|----------|------|-------------|
|
|
| GET | `/api/map/canvass/volunteer/assignments` | Any logged-in user | Get shifts with cut assignments |
|
|
| GET | `/api/map/canvass/volunteer/stats` | Any logged-in user | Get volunteer canvass statistics |
|
|
| GET | `/api/map/canvass/volunteer/visits` | Any logged-in user | List own canvass visits with pagination |
|
|
| POST | `/api/map/canvass/sessions` | Any logged-in user | Start new canvass session |
|
|
| PATCH | `/api/map/canvass/sessions/:id` | Any logged-in user | Update session (end session) |
|
|
| GET | `/api/map/canvass/sessions/:id/addresses` | Any logged-in user | Get addresses within session cut |
|
|
| POST | `/api/map/canvass/sessions/:id/route` | Any logged-in user | Calculate walking route |
|
|
| POST | `/api/map/canvass/visits` | Any logged-in user | Record single visit |
|
|
| POST | `/api/map/canvass/visits/bulk` | Any logged-in user | Record multiple visits (batch) |
|
|
| PATCH | `/api/map/canvass/volunteer/locations/:id` | Any logged-in user | Update location from canvass |
|
|
|
|
**Admin Endpoints:**
|
|
|
|
| Method | Endpoint | Auth | Description |
|
|
|--------|----------|------|-------------|
|
|
| GET | `/api/map/canvass/admin/activity` | MAP_ADMIN | Get recent canvass activity feed |
|
|
| GET | `/api/map/canvass/admin/sessions` | MAP_ADMIN | List active canvass sessions |
|
|
| GET | `/api/map/canvass/admin/visits` | MAP_ADMIN | List all canvass visits with filters |
|
|
| GET | `/api/map/canvass/admin/progress` | MAP_ADMIN | Get cut completion progress |
|
|
| GET | `/api/map/canvass/admin/leaderboard` | MAP_ADMIN | Get volunteer visit leaderboard |
|
|
|
|
## Configuration
|
|
|
|
### Environment Variables
|
|
|
|
| Variable | Type | Default | Description |
|
|
|----------|------|---------|-------------|
|
|
| `CANVASS_SESSION_TIMEOUT_HOURS` | number | `12` | Auto-abandon active sessions after N hours |
|
|
| `CANVASS_VISIT_RATE_LIMIT` | number | `30` | Max visits per minute per IP |
|
|
|
|
### Rate Limiting
|
|
|
|
**Visit Recording Rate Limit:**
|
|
|
|
- **Limit**: 30 visits/min per IP address
|
|
- **Window**: 60 seconds
|
|
- **Redis Prefix**: `rl:canvass-visit:`
|
|
- **Purpose**: Prevent accidental bulk submissions from GPS auto-submit bugs
|
|
|
|
### Session Auto-Cleanup
|
|
|
|
**Abandoned Session Detection:**
|
|
|
|
System automatically marks sessions as ABANDONED if:
|
|
|
|
- **Status**: ACTIVE
|
|
- **Started**: >12 hours ago
|
|
- **No Activity**: No visits in last hour
|
|
|
|
**Cleanup Schedule:**
|
|
|
|
- **Startup**: On API server startup (check all active sessions)
|
|
- **Cron**: Every hour at :00 (setInterval)
|
|
|
|
```typescript
|
|
// api/src/server.ts
|
|
setInterval(async () => {
|
|
await canvassService.cleanupAbandonedSessions();
|
|
}, 60 * 60 * 1000); // 1 hour
|
|
```
|
|
|
|
## Admin Workflow
|
|
|
|
### Viewing Active Sessions
|
|
|
|
**Step 1: Navigate to Canvass Dashboard**
|
|
|
|
Navigate to **Map → Canvass Dashboard** in the admin sidebar.
|
|
|
|
![CanvassDashboardPage Screenshot Placeholder]
|
|
|
|
**Step 2: View Active Sessions**
|
|
|
|
**Active Sessions** card displays:
|
|
|
|
- **Volunteer Name**: Who is canvassing
|
|
- **Cut Name**: Territory being canvassed
|
|
- **Start Time**: When session started
|
|
- **Duration**: Time elapsed (live updating)
|
|
- **Visits**: Number of visits recorded
|
|
- **Status**: ACTIVE badge
|
|
|
|
**Step 3: View Session Details**
|
|
|
|
Click session row to view:
|
|
|
|
- **Volunteer GPS Location**: Last known position on map
|
|
- **Route**: Walking route polyline
|
|
- **Visited Addresses**: Green markers
|
|
- **Unvisited Addresses**: Blue markers
|
|
- **Recent Visits**: Last 10 visits with outcomes
|
|
|
|
### Monitoring Canvass Activity
|
|
|
|
**Step 1: View Activity Feed**
|
|
|
|
**Recent Activity** section displays:
|
|
|
|
- **Volunteer Name**: Who recorded visit
|
|
- **Address**: Location visited
|
|
- **Outcome**: Visit result (icon + label)
|
|
- **Support Level**: Updated support level (color-coded)
|
|
- **Time Ago**: "5 minutes ago"
|
|
|
|
**Step 2: Filter Activity**
|
|
|
|
Use filters:
|
|
|
|
- **Date Range**: Last hour / day / week / month
|
|
- **Outcome**: Filter by specific outcome type
|
|
- **Volunteer**: Filter by volunteer name
|
|
- **Cut**: Filter by territory
|
|
|
|
**Step 3: Export Activity**
|
|
|
|
Click **Export CSV** to download activity feed for reporting.
|
|
|
|
### Tracking Cut Completion
|
|
|
|
**Step 1: View Cut Progress**
|
|
|
|
**Cut Progress** card displays:
|
|
|
|
- **Cut Name**: Territory name
|
|
- **Total Addresses**: Count of addresses in cut
|
|
- **Visited**: Count of addresses with CanvassVisit records
|
|
- **Completion**: Percentage (progress bar)
|
|
- **Last Activity**: Time since last visit
|
|
|
|
**Step 2: View Detailed Progress**
|
|
|
|
Click cut row to view:
|
|
|
|
- **Address List**: All addresses in cut with visit status
|
|
- **Visit Heatmap**: Map showing visited (green) vs unvisited (blue)
|
|
- **Outcome Breakdown**: Pie chart of visit outcomes
|
|
- **Volunteer Breakdown**: Who visited which addresses
|
|
|
|
### Volunteer Leaderboard
|
|
|
|
**Step 1: View Leaderboard**
|
|
|
|
**Leaderboard** card displays:
|
|
|
|
- **Rank**: 1st, 2nd, 3rd place
|
|
- **Volunteer Name**: Volunteer name
|
|
- **Total Visits**: Visit count
|
|
- **Doors/Hour**: Efficiency metric
|
|
- **Top Outcome**: Most common outcome
|
|
|
|
**Step 2: Filter by Time Period**
|
|
|
|
Toggle time period:
|
|
|
|
- **Today**: Visits since midnight
|
|
- **This Week**: Visits since Monday
|
|
- **This Month**: Visits since 1st of month
|
|
- **All Time**: Total visits
|
|
|
|
## Volunteer Workflow
|
|
|
|
### Starting a Canvass Session
|
|
|
|
**Step 1: Login**
|
|
|
|
Login at `/login` with volunteer account (or use TEMP account from shift signup).
|
|
|
|
**Step 2: View Assignments**
|
|
|
|
Navigate to **Volunteer → My Assignments**.
|
|
|
|
**Step 3: Select Shift**
|
|
|
|
Click **Start Canvass** on a shift with cut assignment.
|
|
|
|
**Step 4: Grant GPS Permission**
|
|
|
|
Browser requests geolocation permission. Click **Allow**.
|
|
|
|
**Step 5: Start Session**
|
|
|
|
System redirects to `/volunteer/canvass/:cutId` (full-screen map).
|
|
|
|
System will:
|
|
|
|
1. Create CanvassSession (status=ACTIVE)
|
|
2. Create TrackingSession (linked 1:1)
|
|
3. Load addresses within cut polygon
|
|
4. Calculate walking route from current GPS position
|
|
5. Start GPS auto-tracking (submit points every 10s)
|
|
|
|
### Following Walking Route
|
|
|
|
**Step 1: View Route on Map**
|
|
|
|
Map displays:
|
|
|
|
- **Blue Polyline**: Optimized walking route
|
|
- **Blue Markers**: Unvisited addresses (ordered by route)
|
|
- **Green Markers**: Visited addresses
|
|
- **Red Marker**: Current GPS position (live updating)
|
|
|
|
**Step 2: Navigate to First Address**
|
|
|
|
Follow route to nearest unvisited address. Route recalculates when you move.
|
|
|
|
**Step 3: View Address Details**
|
|
|
|
Tap marker to view:
|
|
|
|
- **Address**: Street address + unit number
|
|
- **Resident Name**: First/Last name (if available)
|
|
- **Support Level**: Previous support level (if available)
|
|
- **Last Visit**: Previous visit outcome + date (if applicable)
|
|
|
|
### Recording a Visit
|
|
|
|
**Step 1: Knock on Door**
|
|
|
|
Approach address and knock/ring doorbell.
|
|
|
|
**Step 2: Open Visit Recording Form**
|
|
|
|
Tap **Record Visit** button in bottom toolbar. Bottom sheet slides up.
|
|
|
|
**Step 3: Select Outcome**
|
|
|
|
Choose visit outcome:
|
|
|
|
- **Not Home**: Nobody answered
|
|
- **Refused**: Refused to speak
|
|
- **Moved**: Resident moved away
|
|
- **Already Voted**: Already voted (GOTV campaigns)
|
|
- **Spoke With**: Had conversation
|
|
- **Left Literature**: Left campaign material
|
|
- **Come Back Later**: Asked to return later
|
|
|
|
**Step 4: Update Support Level (if applicable)**
|
|
|
|
For "Spoke With" outcome, select support level:
|
|
|
|
- **Level 1 (Strong)**: Green badge
|
|
- **Level 2 (Leaning)**: Yellow badge
|
|
- **Level 3 (Undecided)**: Gray badge
|
|
- **Level 4 (Opposed)**: Red badge
|
|
|
|
**Step 5: Sign Request (optional)**
|
|
|
|
Toggle **Sign Requested** if resident wants lawn/window sign.
|
|
|
|
**Step 6: Add Notes (optional)**
|
|
|
|
Enter free-text notes (e.g., "Asked about healthcare policy", "Concerned about taxes").
|
|
|
|
**Step 7: Save Visit**
|
|
|
|
Tap **Save Visit**. System will:
|
|
|
|
1. Create CanvassVisit record with outcome + timestamp
|
|
2. Update Address with new support level + sign status + notes
|
|
3. Increment session.totalVisits count
|
|
4. Update cut.completionPercentage
|
|
5. Create LocationHistory audit record
|
|
6. Submit GPS trackpoint with eventType=VISIT_RECORDED
|
|
7. Update marker to green (visited)
|
|
8. Recalculate walking route (exclude visited address)
|
|
|
|
### Ending a Canvass Session
|
|
|
|
**Step 1: Finish Route**
|
|
|
|
Complete visits for all addresses (or as many as possible).
|
|
|
|
**Step 2: End Session**
|
|
|
|
Tap **End Session** button in header.
|
|
|
|
**Step 3: Confirm**
|
|
|
|
Confirmation modal displays session summary:
|
|
|
|
- **Duration**: Total session time
|
|
- **Visits**: Number of visits recorded
|
|
- **Distance**: Total distance walked (from GPS tracking)
|
|
- **Doors/Hour**: Efficiency metric
|
|
|
|
**Step 4: Submit**
|
|
|
|
Tap **End Session**. System will:
|
|
|
|
1. Update CanvassSession (status=COMPLETED, endedAt=now)
|
|
2. End TrackingSession (isActive=false, endedAt=now)
|
|
3. Calculate final stats (totalVisits, totalDistanceM)
|
|
4. Redirect to `/volunteer/activity` (visit history page)
|
|
|
|
### Viewing Visit History
|
|
|
|
**Step 1: Navigate to My Activity**
|
|
|
|
Navigate to **Volunteer → My Activity**.
|
|
|
|
**Step 2: View Visit List**
|
|
|
|
Table displays:
|
|
|
|
- **Address**: Location visited
|
|
- **Outcome**: Visit result (icon + label)
|
|
- **Support Level**: Updated support level
|
|
- **Visit Date**: Formatted date/time
|
|
- **Notes**: Canvassing notes (truncated)
|
|
|
|
**Step 3: Filter Visits**
|
|
|
|
Use filters:
|
|
|
|
- **Date Range**: Last week / month / all time
|
|
- **Outcome**: Filter by specific outcome
|
|
- **Support Level**: Filter by support level
|
|
|
|
**Step 4: View Session History**
|
|
|
|
Navigate to **My Routes** to view:
|
|
|
|
- **Session List**: Past canvass sessions
|
|
- **Session Map**: GPS route polyline + visited markers
|
|
- **Session Stats**: Duration, visits, distance, doors/hour
|
|
|
|
## Code Examples
|
|
|
|
### Start Canvass Session (Backend)
|
|
|
|
```typescript
|
|
// api/src/modules/map/canvass/canvass.service.ts
|
|
async startSession(userId: string, data: StartSessionInput) {
|
|
const { cutId, shiftId, startLat, startLng } = data;
|
|
|
|
// Check for existing active session
|
|
const existing = await prisma.canvassSession.findFirst({
|
|
where: { userId, status: CanvassSessionStatus.ACTIVE },
|
|
});
|
|
|
|
if (existing) {
|
|
throw new AppError(400, 'Already have an active session', 'SESSION_ACTIVE');
|
|
}
|
|
|
|
// Create session + tracking session in transaction
|
|
const session = await prisma.$transaction(async (tx) => {
|
|
const canvassSession = await tx.canvassSession.create({
|
|
data: {
|
|
userId,
|
|
cutId,
|
|
shiftId,
|
|
status: CanvassSessionStatus.ACTIVE,
|
|
},
|
|
});
|
|
|
|
if (startLat && startLng) {
|
|
await tx.trackingSession.create({
|
|
data: {
|
|
userId,
|
|
canvassSessionId: canvassSession.id,
|
|
lastLatitude: new Prisma.Decimal(startLat),
|
|
lastLongitude: new Prisma.Decimal(startLng),
|
|
lastRecordedAt: new Date(),
|
|
},
|
|
});
|
|
}
|
|
|
|
return canvassSession;
|
|
});
|
|
|
|
setActiveCanvassSessions(
|
|
await prisma.canvassSession.count({
|
|
where: { status: CanvassSessionStatus.ACTIVE },
|
|
})
|
|
);
|
|
|
|
return session;
|
|
}
|
|
```
|
|
|
|
### Calculate Walking Route (Backend)
|
|
|
|
```typescript
|
|
// api/src/modules/map/canvass/canvass-route.service.ts
|
|
export function calculateWalkingRoute(
|
|
locations: RouteLocation[],
|
|
startLat?: number,
|
|
startLng?: number,
|
|
cutGeojson?: string,
|
|
): RouteResult {
|
|
if (locations.length === 0) {
|
|
return { orderedLocations: [], totalDistanceMeters: 0, estimatedMinutes: 0 };
|
|
}
|
|
|
|
// Determine starting point
|
|
let currentLat: number;
|
|
let currentLng: number;
|
|
|
|
if (startLat !== undefined && startLng !== undefined) {
|
|
currentLat = startLat;
|
|
currentLng = startLng;
|
|
} else if (cutGeojson) {
|
|
const polygons = parseGeoJsonPolygon(cutGeojson);
|
|
const centroid = calculateCentroid(polygons[0]!);
|
|
currentLat = centroid.lat;
|
|
currentLng = centroid.lng;
|
|
} else {
|
|
// Use first location as starting point
|
|
currentLat = locations[0]!.latitude;
|
|
currentLng = locations[0]!.longitude;
|
|
}
|
|
|
|
const remaining = [...locations];
|
|
const ordered: RouteLocation[] = [];
|
|
let totalDistance = 0;
|
|
|
|
// Nearest-neighbor algorithm
|
|
while (remaining.length > 0) {
|
|
let nearestIdx = 0;
|
|
let nearestDist = Infinity;
|
|
|
|
for (let i = 0; i < remaining.length; i++) {
|
|
const loc = remaining[i]!;
|
|
const dist = haversineDistance(currentLat, currentLng, loc.latitude, loc.longitude);
|
|
if (dist < nearestDist) {
|
|
nearestDist = dist;
|
|
nearestIdx = i;
|
|
}
|
|
}
|
|
|
|
const nearest = remaining.splice(nearestIdx, 1)[0]!;
|
|
ordered.push(nearest);
|
|
totalDistance += nearestDist;
|
|
currentLat = nearest.latitude;
|
|
currentLng = nearest.longitude;
|
|
}
|
|
|
|
const WALKING_SPEED_MPS = 5000 / 60; // 5 km/h in m/min
|
|
const MINUTES_PER_DOOR = 2;
|
|
const walkingMinutes = totalDistance / WALKING_SPEED_MPS;
|
|
const doorMinutes = ordered.length * MINUTES_PER_DOOR;
|
|
const estimatedMinutes = Math.round(walkingMinutes + doorMinutes);
|
|
|
|
return {
|
|
orderedLocations: ordered,
|
|
totalDistanceMeters: Math.round(totalDistance),
|
|
estimatedMinutes,
|
|
};
|
|
}
|
|
```
|
|
|
|
### Record Visit (Backend)
|
|
|
|
```typescript
|
|
// api/src/modules/map/canvass/canvass.service.ts
|
|
async recordVisit(userId: string, data: RecordVisitInput) {
|
|
const { sessionId, addressId, outcome, supportLevel, signRequested, notes } = data;
|
|
|
|
// Verify session ownership and active status
|
|
const session = await prisma.canvassSession.findFirst({
|
|
where: { id: sessionId, userId, status: CanvassSessionStatus.ACTIVE },
|
|
});
|
|
|
|
if (!session) {
|
|
throw new AppError(404, 'Active session not found', 'SESSION_NOT_FOUND');
|
|
}
|
|
|
|
// Create visit + update address in transaction
|
|
const visit = await prisma.$transaction(async (tx) => {
|
|
const canvassVisit = await tx.canvassVisit.create({
|
|
data: {
|
|
sessionId,
|
|
userId,
|
|
addressId,
|
|
outcome,
|
|
supportLevel,
|
|
signRequested: signRequested ?? false,
|
|
notes,
|
|
},
|
|
});
|
|
|
|
// Update address with new data
|
|
if (supportLevel || signRequested !== undefined || notes) {
|
|
await tx.address.update({
|
|
where: { id: addressId },
|
|
data: {
|
|
...(supportLevel && { supportLevel }),
|
|
...(signRequested !== undefined && { sign: signRequested }),
|
|
...(notes && { notes }),
|
|
},
|
|
});
|
|
}
|
|
|
|
// Increment session visit count
|
|
await tx.canvassSession.update({
|
|
where: { id: sessionId },
|
|
data: { totalVisits: { increment: 1 } },
|
|
});
|
|
|
|
// Update cut completion percentage
|
|
if (session.cutId) {
|
|
const cutId = session.cutId;
|
|
const totalAddresses = await tx.address.count({
|
|
where: {
|
|
location: {
|
|
// Point-in-polygon query omitted for brevity
|
|
},
|
|
},
|
|
});
|
|
|
|
const visitedAddresses = await tx.canvassVisit.count({
|
|
where: {
|
|
session: { cutId },
|
|
},
|
|
});
|
|
|
|
const completionPercentage = Math.round((visitedAddresses / totalAddresses) * 100);
|
|
|
|
await tx.cut.update({
|
|
where: { id: cutId },
|
|
data: { completionPercentage },
|
|
});
|
|
}
|
|
|
|
return canvassVisit;
|
|
});
|
|
|
|
recordCanvassVisit(outcome);
|
|
|
|
return visit;
|
|
}
|
|
```
|
|
|
|
### GPS Auto-Tracking (Frontend)
|
|
|
|
```typescript
|
|
// admin/src/components/canvass/GPSTracker.tsx
|
|
useEffect(() => {
|
|
if (!session || !geolocationEnabled) return;
|
|
|
|
const watchId = navigator.geolocation.watchPosition(
|
|
(position) => {
|
|
const point = {
|
|
latitude: position.coords.latitude,
|
|
longitude: position.coords.longitude,
|
|
accuracy: position.coords.accuracy,
|
|
recordedAt: new Date().toISOString(),
|
|
};
|
|
|
|
// Add to local buffer
|
|
setPointsBuffer((prev) => [...prev, point]);
|
|
|
|
// Update current position
|
|
setCurrentPosition([point.latitude, point.longitude]);
|
|
},
|
|
(error) => {
|
|
console.error('GPS error:', error);
|
|
message.error('GPS tracking failed');
|
|
},
|
|
{
|
|
enableHighAccuracy: true,
|
|
maximumAge: 0,
|
|
timeout: 10000,
|
|
}
|
|
);
|
|
|
|
// Submit buffered points every 10 seconds
|
|
const interval = setInterval(async () => {
|
|
if (pointsBuffer.length === 0) return;
|
|
|
|
try {
|
|
await api.post(`/map/tracking/sessions/${trackingSessionId}/points`, {
|
|
points: pointsBuffer,
|
|
});
|
|
|
|
setPointsBuffer([]);
|
|
} catch (error) {
|
|
console.error('Failed to submit GPS points:', error);
|
|
}
|
|
}, 10000);
|
|
|
|
return () => {
|
|
navigator.geolocation.clearWatch(watchId);
|
|
clearInterval(interval);
|
|
};
|
|
}, [session, geolocationEnabled, pointsBuffer, trackingSessionId]);
|
|
```
|
|
|
|
### Visit Recording Form (Frontend)
|
|
|
|
```typescript
|
|
// admin/src/components/canvass/VisitRecordingForm.tsx
|
|
const handleSubmit = async (values: any) => {
|
|
try {
|
|
await api.post('/map/canvass/visits', {
|
|
sessionId: session.id,
|
|
addressId: selectedAddress.id,
|
|
outcome: values.outcome,
|
|
supportLevel: values.supportLevel,
|
|
signRequested: values.signRequested,
|
|
notes: values.notes,
|
|
});
|
|
|
|
message.success('Visit recorded');
|
|
form.resetFields();
|
|
onVisitRecorded();
|
|
} catch (error) {
|
|
message.error('Failed to record visit');
|
|
}
|
|
};
|
|
```
|
|
|
|
## Troubleshooting
|
|
|
|
### Issue: Walking Route Not Optimal
|
|
|
|
**Symptoms:**
|
|
|
|
- Route backtracks frequently
|
|
- Total distance much longer than expected
|
|
- Route doesn't start from volunteer GPS position
|
|
|
|
**Causes:**
|
|
|
|
- Nearest-neighbor algorithm is greedy (not globally optimal)
|
|
- Starting position not provided (defaults to cut centroid)
|
|
- GPS accuracy poor (volunteer position inaccurate)
|
|
|
|
**Solutions:**
|
|
|
|
1. **Use volunteer GPS position as start**:
|
|
|
|
```typescript
|
|
// Always pass volunteer GPS position to route calculation
|
|
const route = await calculateWalkingRoute(
|
|
locations,
|
|
currentLat,
|
|
currentLng,
|
|
cut.geojson
|
|
);
|
|
```
|
|
|
|
2. **Consider alternative algorithms**:
|
|
|
|
For better optimization, use 2-opt or genetic algorithms (computationally expensive):
|
|
|
|
```typescript
|
|
// Install optimization library
|
|
npm install routing-js
|
|
|
|
// Use 2-opt algorithm
|
|
import { twoOpt } from 'routing-js';
|
|
const optimized = twoOpt(locations, distanceMatrix);
|
|
```
|
|
|
|
3. **Pre-optimize routes for shifts**:
|
|
|
|
Admin can pre-calculate optimal routes and assign to volunteers:
|
|
|
|
```typescript
|
|
// Calculate route once, store in Shift model
|
|
const route = await calculateWalkingRoute(locations);
|
|
await prisma.shift.update({
|
|
where: { id: shiftId },
|
|
data: { preCalculatedRoute: JSON.stringify(route) },
|
|
});
|
|
```
|
|
|
|
### Issue: Session Auto-Abandoned Prematurely
|
|
|
|
**Symptoms:**
|
|
|
|
- Active session marked ABANDONED while volunteer still canvassing
|
|
- Session timeout after <12 hours
|
|
- Volunteer can't record visits after timeout
|
|
|
|
**Causes:**
|
|
|
|
- `CANVASS_SESSION_TIMEOUT_HOURS` set too low
|
|
- Volunteer paused for lunch/break (no activity for >1 hour)
|
|
- System clock drift
|
|
|
|
**Solutions:**
|
|
|
|
1. **Increase timeout**:
|
|
|
|
```bash
|
|
# In .env
|
|
CANVASS_SESSION_TIMEOUT_HOURS=24 # Was 12, increase to 24
|
|
```
|
|
|
|
2. **Record "heartbeat" visits**:
|
|
|
|
Add periodic "still active" ping to prevent timeout:
|
|
|
|
```typescript
|
|
// Volunteer app sends heartbeat every 30 minutes
|
|
setInterval(async () => {
|
|
await api.post(`/map/canvass/sessions/${sessionId}/heartbeat`);
|
|
}, 30 * 60 * 1000);
|
|
```
|
|
|
|
3. **Allow session resumption**:
|
|
|
|
Let volunteers resume ABANDONED sessions:
|
|
|
|
```typescript
|
|
// Backend: Add resume endpoint
|
|
async resumeSession(userId: string, sessionId: string) {
|
|
const session = await prisma.canvassSession.findFirst({
|
|
where: { id: sessionId, userId, status: CanvassSessionStatus.ABANDONED },
|
|
});
|
|
|
|
if (!session) {
|
|
throw new AppError(404, 'Abandoned session not found', 'SESSION_NOT_FOUND');
|
|
}
|
|
|
|
return prisma.canvassSession.update({
|
|
where: { id: sessionId },
|
|
data: { status: CanvassSessionStatus.ACTIVE },
|
|
});
|
|
}
|
|
```
|
|
|
|
### Issue: GPS Tracking Draining Battery
|
|
|
|
**Symptoms:**
|
|
|
|
- Volunteer phone battery drains rapidly
|
|
- Phone overheats during canvassing
|
|
- GPS tracking fails after 2-3 hours
|
|
|
|
**Causes:**
|
|
|
|
- `enableHighAccuracy` uses GPS + WiFi + cellular (power-hungry)
|
|
- Watchposition submits too frequently (every second)
|
|
- Screen stays on during entire session
|
|
|
|
**Solutions:**
|
|
|
|
1. **Reduce GPS accuracy**:
|
|
|
|
```typescript
|
|
navigator.geolocation.watchPosition(
|
|
callback,
|
|
errorCallback,
|
|
{
|
|
enableHighAccuracy: false, // Use WiFi/cellular only (less accurate but lower power)
|
|
maximumAge: 5000, // Cache position for 5s
|
|
timeout: 30000, // Longer timeout
|
|
}
|
|
);
|
|
```
|
|
|
|
2. **Reduce submission frequency**:
|
|
|
|
```typescript
|
|
// Submit GPS points every 30s instead of 10s
|
|
const SUBMIT_INTERVAL_MS = 30000; // Was 10000
|
|
```
|
|
|
|
3. **Pause tracking during breaks**:
|
|
|
|
Add "Pause Tracking" button to stop GPS watchPosition:
|
|
|
|
```typescript
|
|
const pauseTracking = () => {
|
|
navigator.geolocation.clearWatch(watchId);
|
|
setTrackingPaused(true);
|
|
};
|
|
|
|
const resumeTracking = () => {
|
|
// Start watchPosition again
|
|
setTrackingPaused(false);
|
|
};
|
|
```
|
|
|
|
## Performance Considerations
|
|
|
|
### Visit Recording Rate Limiting
|
|
|
|
**Prevent Abuse:**
|
|
|
|
Rate limit prevents accidental bulk submissions:
|
|
|
|
```typescript
|
|
// api/src/middleware/rate-limit.ts
|
|
const canvassVisitLimiter = new RateLimiterRedis({
|
|
storeClient: redis,
|
|
keyPrefix: 'rl:canvass-visit:',
|
|
points: 30, // 30 visits
|
|
duration: 60, // per 60 seconds
|
|
blockDuration: 300, // block for 5 minutes if exceeded
|
|
});
|
|
```
|
|
|
|
**Legitimate Use Cases:**
|
|
|
|
- **Bulk data entry**: Admin can bypass rate limit for importing historical data
|
|
- **Offline sync**: Mobile app queues visits offline, submits when online (batch endpoint)
|
|
|
|
### Session Cleanup Performance
|
|
|
|
**Efficient Abandoned Session Query:**
|
|
|
|
```sql
|
|
-- Index for abandoned session cleanup
|
|
CREATE INDEX idx_canvass_sessions_abandoned ON "CanvassSession" ("status", "startedAt")
|
|
WHERE status = 'ACTIVE';
|
|
|
|
-- Efficient query
|
|
SELECT id FROM "CanvassSession"
|
|
WHERE status = 'ACTIVE'
|
|
AND "startedAt" < NOW() - INTERVAL '12 hours';
|
|
```
|
|
|
|
### Cut Completion Calculation
|
|
|
|
**Avoid N+1 Queries:**
|
|
|
|
```typescript
|
|
// Inefficient: query per cut
|
|
for (const cut of cuts) {
|
|
const visited = await prisma.canvassVisit.count({
|
|
where: { session: { cutId: cut.id } },
|
|
});
|
|
const total = await getAddressesInCut(cut.id).length;
|
|
cut.completionPercentage = (visited / total) * 100;
|
|
}
|
|
|
|
// Efficient: single aggregation query
|
|
const completionStats = await prisma.canvassSession.groupBy({
|
|
by: ['cutId'],
|
|
where: { status: CanvassSessionStatus.COMPLETED },
|
|
_count: { visits: true },
|
|
});
|
|
```
|
|
|
|
## Related Documentation
|
|
|
|
**Backend Modules:**
|
|
|
|
- [Canvass Backend Module](../../backend/modules/map/canvass.md) — API implementation
|
|
- [Walking Route Service](../../backend/modules/map/canvass-route.md) — Route optimization
|
|
- [Tracking Service](../../backend/modules/map/tracking.md) — GPS tracking
|
|
|
|
**Frontend Pages:**
|
|
|
|
- [VolunteerMapPage](../../frontend/pages/volunteer/volunteer-map-page.md) — Full-screen canvass map
|
|
- [CanvassDashboardPage](../../frontend/pages/admin/canvass-dashboard.md) — Admin oversight
|
|
- [MyActivityPage](../../frontend/pages/volunteer/my-activity-page.md) — Visit history
|
|
|
|
**Database:**
|
|
|
|
- [CanvassSession Model](../../database/models/canvass.md#canvasssession-model) — Session schema
|
|
- [CanvassVisit Model](../../database/models/canvass.md#canvassvisit-model) — Visit records
|
|
- [TrackingSession Model](../../database/models/canvass.md#trackingsession-model) — GPS tracking
|
|
|
|
**Features:**
|
|
|
|
- [Cuts](./cuts.md) — Territory boundaries for canvassing
|
|
- [Shifts](./shifts.md) — Shift-based canvass scheduling
|
|
- [Tracking](./tracking.md) — GPS tracking system
|
|
- [Locations](./locations.md) — Address management
|