1124 lines
28 KiB
Markdown
1124 lines
28 KiB
Markdown
# Canvass Module
|
|
|
|
## Overview
|
|
|
|
The Canvass module powers the volunteer canvassing system, enabling door-to-door outreach with GPS tracking, visit recording, walking route optimization, and real-time progress monitoring. It features role-based permissions, automated session management, and comprehensive analytics for campaign organizers.
|
|
|
|
**Key Features:**
|
|
|
|
- Canvass session management (start, end, abandon detection)
|
|
- Visit recording with outcomes (CONTACTED, SUPPORTER, NOT_HOME, REFUSED, etc.)
|
|
- Bulk visit recording (mark entire building as NOT_HOME)
|
|
- Walking route optimization (nearest-neighbor algorithm)
|
|
- GPS-enabled location tracking
|
|
- Role-gated field editing (volunteers update support data, admins update PII)
|
|
- Real-time cut completion percentage calculation
|
|
- Admin dashboard (stats, activity feed, volunteer leaderboard)
|
|
- Shift-based assignments (volunteers assigned to cuts via shifts)
|
|
- Rate limiting (30 visits/min per IP, 10 bulk visits/min)
|
|
- Abandoned session cleanup (ACTIVE > 12h → ABANDONED)
|
|
|
|
## File Paths
|
|
|
|
| File | Purpose |
|
|
|------|---------|
|
|
| `api/src/modules/map/canvass/canvass.routes.ts` | 2 routers (volunteer + admin) with 22 endpoints |
|
|
| `api/src/modules/map/canvass/canvass.service.ts` | Canvass business logic + session management |
|
|
| `api/src/modules/map/canvass/canvass.schemas.ts` | Zod validation schemas |
|
|
| `api/src/modules/map/canvass/canvass-route.service.ts` | Walking route optimization algorithm |
|
|
|
|
## Database Models
|
|
|
|
### CanvassSession
|
|
|
|
```prisma
|
|
model CanvassSession {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
cutId String
|
|
cut Cut @relation(fields: [cutId], references: [id], onDelete: Cascade)
|
|
shiftId String?
|
|
shift Shift? @relation(fields: [shiftId], references: [id], onDelete: SetNull)
|
|
status CanvassSessionStatus @default(ACTIVE)
|
|
startLatitude Float?
|
|
startLongitude Float?
|
|
startedAt DateTime @default(now())
|
|
endedAt DateTime?
|
|
visits CanvassVisit[]
|
|
|
|
@@index([userId])
|
|
@@index([cutId])
|
|
@@index([status])
|
|
@@map("canvass_sessions")
|
|
}
|
|
|
|
enum CanvassSessionStatus {
|
|
ACTIVE // Currently canvassing
|
|
COMPLETED // Ended by volunteer
|
|
ABANDONED // Auto-closed after 12h
|
|
}
|
|
```
|
|
|
|
### CanvassVisit
|
|
|
|
```prisma
|
|
model CanvassVisit {
|
|
id String @id @default(cuid())
|
|
addressId String // Changed from locationId to support multi-unit buildings
|
|
address Address @relation(fields: [addressId], references: [id], onDelete: Cascade)
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
sessionId String?
|
|
session CanvassSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull)
|
|
shiftId String?
|
|
shift Shift? @relation(fields: [shiftId], references: [id], onDelete: SetNull)
|
|
outcome VisitOutcome
|
|
supportLevel SupportLevel?
|
|
signRequested Boolean @default(false)
|
|
signSize String?
|
|
notes String? @db.Text
|
|
durationSeconds Int?
|
|
visitedAt DateTime @default(now())
|
|
|
|
@@index([addressId])
|
|
@@index([userId])
|
|
@@index([sessionId])
|
|
@@index([outcome])
|
|
@@map("canvass_visits")
|
|
}
|
|
|
|
enum VisitOutcome {
|
|
CONTACTED // Successful conversation
|
|
SUPPORTER // Supporter identified
|
|
NOT_HOME // No answer
|
|
REFUSED // Declined conversation
|
|
MOVED // No longer at address
|
|
WRONG_ADDRESS // Address doesn't exist
|
|
CALLBACK // Requested follow-up
|
|
INACCESSIBLE // Cannot access (locked building, no entry)
|
|
}
|
|
```
|
|
|
|
### Address Model (Multi-Unit Support)
|
|
|
|
```prisma
|
|
model Address {
|
|
id String @id @default(cuid())
|
|
locationId String
|
|
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
|
unitNumber String?
|
|
firstName String?
|
|
lastName String?
|
|
email String?
|
|
phone String?
|
|
supportLevel SupportLevel?
|
|
sign Boolean @default(false)
|
|
signSize String?
|
|
notes String? @db.Text
|
|
visits CanvassVisit[]
|
|
|
|
@@index([locationId])
|
|
@@map("addresses")
|
|
}
|
|
```
|
|
|
|
**Multi-Unit Building Support:**
|
|
|
|
- `Location` — Physical building (lat/lng, address, buildingNotes)
|
|
- `Address` — Individual unit within building (unitNumber, firstName, lastName, supportLevel, etc.)
|
|
- `CanvassVisit` — Links to `Address` (not `Location`) for per-unit tracking
|
|
|
|
---
|
|
|
|
## API Endpoints
|
|
|
|
### Volunteer Endpoints (Authentication Required, Any Role)
|
|
|
|
| Method | Path | Description |
|
|
|--------|------|-------------|
|
|
| GET | `/api/map/canvass/my/assignments` | Get assigned shifts with cuts |
|
|
| GET | `/api/map/canvass/my/stats` | Get volunteer statistics |
|
|
| GET | `/api/map/canvass/my/visits` | List my visit history (paginated) |
|
|
| GET | `/api/map/canvass/my/session` | Get active canvass session |
|
|
| POST | `/api/map/canvass/sessions` | Start new canvass session |
|
|
| POST | `/api/map/canvass/sessions/:id/end` | End canvass session |
|
|
| GET | `/api/map/canvass/cuts/:cutId/locations` | Get locations in cut for canvassing |
|
|
| GET | `/api/map/canvass/cuts/:cutId/route` | Get optimized walking route |
|
|
| GET | `/api/map/canvass/locations` | Get all locations with visit annotations |
|
|
| PUT | `/api/map/canvass/locations/:id` | Update location (role-gated fields) |
|
|
| POST | `/api/map/canvass/locations` | Create location (role-gated fields) |
|
|
| POST | `/api/map/canvass/reverse-geocode` | Reverse geocode lat/lng |
|
|
| POST | `/api/map/canvass/geocode-search` | Geocode address for map search |
|
|
| POST | `/api/map/canvass/visits` | Record visit (rate-limited: 30/min) |
|
|
| POST | `/api/map/canvass/visits/bulk` | Bulk record visits for building (rate-limited: 10/min) |
|
|
|
|
### Admin Endpoints (Authentication Required, MAP_ADMIN Roles)
|
|
|
|
| Method | Path | Description |
|
|
|--------|------|-------------|
|
|
| GET | `/api/map/canvass/stats` | Get admin statistics |
|
|
| GET | `/api/map/canvass/stats/cuts/:cutId` | Get cut-specific statistics |
|
|
| GET | `/api/map/canvass/activity` | Get recent activity feed (paginated) |
|
|
| GET | `/api/map/canvass/volunteers` | List volunteers with visit counts |
|
|
| GET | `/api/map/canvass/volunteers/:userId` | Get volunteer statistics |
|
|
| GET | `/api/map/canvass/visits` | List all visits (paginated, filtered) |
|
|
|
|
**Admin Roles:** `SUPER_ADMIN`, `MAP_ADMIN`
|
|
|
|
---
|
|
|
|
## Volunteer Endpoint Details
|
|
|
|
### POST /api/map/canvass/sessions
|
|
|
|
Start new canvass session for a cut.
|
|
|
|
**Request Body:**
|
|
|
|
```json
|
|
{
|
|
"cutId": "clxCut123",
|
|
"shiftId": "clxShift456",
|
|
"startLatitude": 43.6532,
|
|
"startLongitude": -79.3832
|
|
}
|
|
```
|
|
|
|
**Response (201 Created):**
|
|
|
|
```json
|
|
{
|
|
"id": "clxSession789",
|
|
"userId": "clxUser123",
|
|
"cutId": "clxCut123",
|
|
"shiftId": "clxShift456",
|
|
"status": "ACTIVE",
|
|
"startLatitude": 43.6532,
|
|
"startLongitude": -79.3832,
|
|
"startedAt": "2026-02-11T14:00:00.000Z",
|
|
"endedAt": null,
|
|
"cut": {
|
|
"id": "clxCut123",
|
|
"name": "Downtown Ward 5"
|
|
},
|
|
"shift": {
|
|
"id": "clxShift456",
|
|
"title": "Saturday Canvass"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Validation:**
|
|
|
|
- Only one active session per user allowed
|
|
- Cut must exist
|
|
- Shift is optional (can canvass outside scheduled shifts)
|
|
|
|
**Error Responses:**
|
|
|
|
- `409 Conflict`: User already has active session
|
|
- `404 Not Found`: Cut not found
|
|
|
|
---
|
|
|
|
### POST /api/map/canvass/sessions/:id/end
|
|
|
|
End active canvass session.
|
|
|
|
**Path Parameters:**
|
|
|
|
- `id` (string): Session ID
|
|
|
|
**Response (200 OK):**
|
|
|
|
Returns updated session with `status: COMPLETED` and `endedAt` timestamp.
|
|
|
|
**Post-Processing:**
|
|
|
|
- Recalculates cut completion percentage
|
|
- Updates Prometheus metrics (active sessions gauge)
|
|
|
|
**Validation:**
|
|
|
|
- Session must belong to authenticated user
|
|
- Session must be ACTIVE (not already completed/abandoned)
|
|
|
|
---
|
|
|
|
### GET /api/map/canvass/my/assignments
|
|
|
|
Get volunteer's assigned shifts with associated cuts.
|
|
|
|
**Example Response (200 OK):**
|
|
|
|
```json
|
|
[
|
|
{
|
|
"shiftId": "clxShift456",
|
|
"shiftTitle": "Saturday Canvass",
|
|
"shiftDate": "2026-02-15",
|
|
"startTime": "10:00",
|
|
"endTime": "14:00",
|
|
"location": "Community Center, 123 Main St",
|
|
"cutId": "clxCut123",
|
|
"cutName": "Downtown Ward 5",
|
|
"completionPercentage": 42
|
|
}
|
|
]
|
|
```
|
|
|
|
**Filtering:**
|
|
|
|
- Only returns confirmed signups (`status: CONFIRMED`)
|
|
- Only returns shifts with associated cuts (`cutId` not null)
|
|
- Ordered by shift date ascending (upcoming shifts first)
|
|
|
|
---
|
|
|
|
### GET /api/map/canvass/cuts/:cutId/locations
|
|
|
|
Get locations within cut for canvassing with visit annotations.
|
|
|
|
**Path Parameters:**
|
|
|
|
- `cutId` (string): Cut ID
|
|
|
|
**Query Parameters:**
|
|
|
|
- `minLat`, `maxLat`, `minLng`, `maxLng` (optional): Bounding box for visible map area
|
|
|
|
**Example Response (200 OK):**
|
|
|
|
```json
|
|
[
|
|
{
|
|
"id": "clxAddress123",
|
|
"unitNumber": "Apt 4",
|
|
"firstName": "John",
|
|
"lastName": "Doe",
|
|
"email": "john@example.com",
|
|
"phone": "416-555-1234",
|
|
"supportLevel": "LEVEL_1",
|
|
"sign": true,
|
|
"signSize": "Large",
|
|
"notes": "Willing to volunteer",
|
|
"location": {
|
|
"id": "clxLocation456",
|
|
"latitude": 43.6532,
|
|
"longitude": -79.3832,
|
|
"address": "123 Main St, Toronto, ON",
|
|
"buildingNotes": "Intercom code: 1234"
|
|
},
|
|
"lastVisit": {
|
|
"outcome": "CONTACTED",
|
|
"visitedAt": "2026-02-10T14:30:00.000Z",
|
|
"visitorName": "Jane Smith",
|
|
"isMyVisit": false
|
|
}
|
|
}
|
|
]
|
|
```
|
|
|
|
**Two-Stage Filtering:**
|
|
|
|
1. **Database bounds filter** — Fast WHERE clause on lat/lng
|
|
2. **Polygon filter** — In-memory point-in-polygon check
|
|
|
|
**Visit Annotations:**
|
|
|
|
- `lastVisit` — Most recent visit to this address (any volunteer)
|
|
- `isMyVisit` — True if authenticated user made last visit
|
|
- Null if address never visited
|
|
|
|
---
|
|
|
|
### GET /api/map/canvass/cuts/:cutId/route
|
|
|
|
Get optimized walking route for cut.
|
|
|
|
**Path Parameters:**
|
|
|
|
- `cutId` (string): Cut ID
|
|
|
|
**Query Parameters:**
|
|
|
|
- `excludeVisited` (boolean, default: false): Exclude already-visited addresses
|
|
- `startLatitude` (number, optional): Starting position latitude
|
|
- `startLongitude` (number, optional): Starting position longitude
|
|
|
|
**Example Response (200 OK):**
|
|
|
|
```json
|
|
{
|
|
"route": [
|
|
{
|
|
"id": "clxAddress123",
|
|
"latitude": 43.6532,
|
|
"longitude": -79.3832,
|
|
"address": "123 Main St",
|
|
"unitNumber": "Apt 4",
|
|
"distanceFromPrevious": 0
|
|
},
|
|
{
|
|
"id": "clxAddress124",
|
|
"latitude": 43.6540,
|
|
"longitude": -79.3825,
|
|
"address": "125 Main St",
|
|
"unitNumber": null,
|
|
"distanceFromPrevious": 92.3
|
|
}
|
|
],
|
|
"totalDistance": 1847.6,
|
|
"estimatedDuration": 1680
|
|
}
|
|
```
|
|
|
|
**Walking Route Algorithm:**
|
|
|
|
Nearest-neighbor greedy algorithm:
|
|
|
|
```typescript
|
|
// Start at provided coordinates or first location
|
|
let current = startCoords || locations[0];
|
|
const route: RouteStop[] = [];
|
|
|
|
while (unvisited.length > 0) {
|
|
// Find nearest unvisited location
|
|
const nearest = findNearest(current, unvisited);
|
|
const distance = haversineDistance(current, nearest);
|
|
|
|
route.push({
|
|
...nearest,
|
|
distanceFromPrevious: distance,
|
|
});
|
|
|
|
current = nearest;
|
|
unvisited = unvisited.filter(loc => loc.id !== nearest.id);
|
|
}
|
|
|
|
// Calculate total distance and duration
|
|
const totalDistance = route.reduce((sum, stop) => sum + stop.distanceFromPrevious, 0);
|
|
const estimatedDuration = Math.ceil(totalDistance / WALKING_SPEED_MPS); // 1.4 m/s
|
|
```
|
|
|
|
**Performance:**
|
|
|
|
- O(n²) complexity (acceptable for typical cut sizes <500 locations)
|
|
- Uses haversine distance (meters) for accurate walking distances
|
|
- Assumes walking speed: 1.4 m/s (5 km/h)
|
|
|
|
---
|
|
|
|
### POST /api/map/canvass/visits
|
|
|
|
Record visit to an address.
|
|
|
|
**Rate Limiting:** 30 requests per minute per IP
|
|
|
|
**Request Body:**
|
|
|
|
```json
|
|
{
|
|
"addressId": "clxAddress123",
|
|
"outcome": "CONTACTED",
|
|
"supportLevel": "LEVEL_2",
|
|
"signRequested": true,
|
|
"signSize": "Large",
|
|
"notes": "Interested in volunteering for phone banks",
|
|
"durationSeconds": 180,
|
|
"sessionId": "clxSession789",
|
|
"shiftId": "clxShift456",
|
|
"updateLocation": true
|
|
}
|
|
```
|
|
|
|
**Field Descriptions:**
|
|
|
|
- `addressId` (required): Address ID (unit within building)
|
|
- `outcome` (required): Visit outcome enum
|
|
- `supportLevel` (optional): Support level identified during visit
|
|
- `signRequested` (optional, default: false): Lawn sign requested
|
|
- `signSize` (optional): Sign size if requested
|
|
- `notes` (optional): Visit notes
|
|
- `durationSeconds` (optional): Time spent at door
|
|
- `sessionId` (optional): Active canvass session ID
|
|
- `shiftId` (optional): Associated shift ID
|
|
- `updateLocation` (optional, default: true): Update address record with visit data
|
|
|
|
**Response (201 Created):**
|
|
|
|
Returns created visit object.
|
|
|
|
**Address Update Logic:**
|
|
|
|
If `updateLocation=true` and outcome is `CONTACTED` or `SUPPORTER`:
|
|
|
|
```typescript
|
|
await prisma.address.update({
|
|
where: { id: addressId },
|
|
data: {
|
|
supportLevel: data.supportLevel || undefined,
|
|
sign: data.signRequested || undefined,
|
|
signSize: data.signRequested ? data.signSize : undefined,
|
|
},
|
|
});
|
|
```
|
|
|
|
**Metrics:**
|
|
|
|
- Increments `cm_canvass_visits_total` counter with outcome label
|
|
- Updates cut completion percentage
|
|
|
|
---
|
|
|
|
### POST /api/map/canvass/visits/bulk
|
|
|
|
Record visit to all unvisited units in a building.
|
|
|
|
**Rate Limiting:** 10 requests per minute per IP (stricter than single visits)
|
|
|
|
**Request Body:**
|
|
|
|
```json
|
|
{
|
|
"locationId": "clxLocation456",
|
|
"outcome": "NOT_HOME",
|
|
"notes": "Building-wide: No answer at any unit",
|
|
"sessionId": "clxSession789",
|
|
"shiftId": "clxShift456"
|
|
}
|
|
```
|
|
|
|
**Allowed Outcomes:**
|
|
|
|
Only non-contact outcomes:
|
|
- `NOT_HOME`
|
|
- `REFUSED`
|
|
- `MOVED`
|
|
|
|
**Logic:**
|
|
|
|
1. Find all addresses at location (building)
|
|
2. Filter to unvisited addresses (no existing visit records)
|
|
3. Create visit records for all unvisited addresses in bulk
|
|
|
|
**Response (201 Created):**
|
|
|
|
```json
|
|
{
|
|
"created": 8,
|
|
"skipped": 2,
|
|
"locationId": "clxLocation456"
|
|
}
|
|
```
|
|
|
|
**Use Cases:**
|
|
|
|
- Large apartment buildings where no one answers buzzer
|
|
- Entire building marked as MOVED (demolished/vacant)
|
|
- Save time: record 10+ units with single action
|
|
|
|
---
|
|
|
|
### PUT /api/map/canvass/locations/:id
|
|
|
|
Update location with role-gated field restrictions.
|
|
|
|
**Path Parameters:**
|
|
|
|
- `id` (string): Address ID
|
|
|
|
**Request Body (Volunteer):**
|
|
|
|
```json
|
|
{
|
|
"supportLevel": "LEVEL_2",
|
|
"sign": true,
|
|
"signSize": "Large",
|
|
"notes": "Willing to volunteer"
|
|
}
|
|
```
|
|
|
|
**Request Body (Admin):**
|
|
|
|
```json
|
|
{
|
|
"firstName": "John",
|
|
"lastName": "Doe",
|
|
"address": "123 Main St, Unit 4",
|
|
"unitNumber": "4",
|
|
"email": "john@example.com",
|
|
"phone": "416-555-1234",
|
|
"supportLevel": "LEVEL_2",
|
|
"sign": true
|
|
}
|
|
```
|
|
|
|
**Role-Gated Fields:**
|
|
|
|
**All Authenticated Users:**
|
|
- `supportLevel`
|
|
- `sign`
|
|
- `signSize`
|
|
- `notes`
|
|
|
|
**Admins Only** (`SUPER_ADMIN`, `MAP_ADMIN`):
|
|
- `firstName`
|
|
- `lastName`
|
|
- `address`
|
|
- `unitNumber`
|
|
- `email`
|
|
- `phone`
|
|
|
|
**TEMP Users:**
|
|
|
|
- Cannot update any fields (read-only canvassing)
|
|
|
|
**Service-Level Field Stripping:**
|
|
|
|
```typescript
|
|
const isAdmin = role === UserRole.SUPER_ADMIN || role === UserRole.MAP_ADMIN;
|
|
const isTemp = role === UserRole.TEMP;
|
|
|
|
if (isTemp) {
|
|
throw new AppError(403, 'TEMP users cannot edit locations', 'FORBIDDEN');
|
|
}
|
|
|
|
const updateData: Prisma.AddressUpdateInput = {};
|
|
|
|
// Volunteer fields (all authenticated users)
|
|
if (data.supportLevel !== undefined) updateData.supportLevel = data.supportLevel;
|
|
if (data.sign !== undefined) updateData.sign = data.sign;
|
|
if (data.signSize !== undefined) updateData.signSize = data.signSize;
|
|
if (data.notes !== undefined) updateData.notes = data.notes;
|
|
|
|
// Admin-only PII fields
|
|
if (isAdmin) {
|
|
if (data.firstName !== undefined) updateData.firstName = data.firstName;
|
|
if (data.lastName !== undefined) updateData.lastName = data.lastName;
|
|
if (data.email !== undefined) updateData.email = data.email;
|
|
if (data.phone !== undefined) updateData.phone = data.phone;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Admin Endpoint Details
|
|
|
|
### GET /api/map/canvass/stats
|
|
|
|
Get aggregate canvassing statistics.
|
|
|
|
**Example Response (200 OK):**
|
|
|
|
```json
|
|
{
|
|
"totalVisits": 3847,
|
|
"totalVolunteers": 42,
|
|
"activeSessions": 7,
|
|
"byOutcome": {
|
|
"CONTACTED": 1892,
|
|
"SUPPORTER": 542,
|
|
"NOT_HOME": 987,
|
|
"REFUSED": 234,
|
|
"MOVED": 89,
|
|
"WRONG_ADDRESS": 43,
|
|
"CALLBACK": 34,
|
|
"INACCESSIBLE": 26
|
|
},
|
|
"topVolunteers": [
|
|
{
|
|
"userId": "clxUser123",
|
|
"name": "Jane Smith",
|
|
"visitCount": 247
|
|
}
|
|
],
|
|
"cutProgress": [
|
|
{
|
|
"cutId": "clxCut123",
|
|
"cutName": "Downtown Ward 5",
|
|
"completionPercentage": 68,
|
|
"visitCount": 342,
|
|
"totalAddresses": 503
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### GET /api/map/canvass/activity
|
|
|
|
Get recent canvass activity feed.
|
|
|
|
**Query Parameters:**
|
|
|
|
- `page` (default: 1): Page number
|
|
- `limit` (default: 20, max: 100): Results per page
|
|
- `cutId` (optional): Filter by cut
|
|
- `userId` (optional): Filter by volunteer
|
|
- `outcome` (optional): Filter by outcome
|
|
|
|
**Example Response (200 OK):**
|
|
|
|
```json
|
|
{
|
|
"activities": [
|
|
{
|
|
"id": "clxVisit789",
|
|
"userId": "clxUser123",
|
|
"user": {
|
|
"name": "Jane Smith",
|
|
"email": "jane@example.com"
|
|
},
|
|
"addressId": "clxAddress456",
|
|
"address": {
|
|
"address": "123 Main St",
|
|
"unitNumber": "Apt 4"
|
|
},
|
|
"outcome": "CONTACTED",
|
|
"supportLevel": "LEVEL_2",
|
|
"visitedAt": "2026-02-11T14:30:00.000Z",
|
|
"durationSeconds": 180
|
|
}
|
|
],
|
|
"pagination": {
|
|
"page": 1,
|
|
"limit": 20,
|
|
"total": 3847,
|
|
"totalPages": 193
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### GET /api/map/canvass/volunteers
|
|
|
|
List volunteers with visit counts.
|
|
|
|
**Example Response (200 OK):**
|
|
|
|
```json
|
|
[
|
|
{
|
|
"userId": "clxUser123",
|
|
"name": "Jane Smith",
|
|
"email": "jane@example.com",
|
|
"totalVisits": 247,
|
|
"todayVisits": 18,
|
|
"activeSessions": 1
|
|
}
|
|
]
|
|
```
|
|
|
|
---
|
|
|
|
## Service Functions
|
|
|
|
### canvassService.startSession(userId, data)
|
|
|
|
Start new canvass session.
|
|
|
|
**Validation:**
|
|
|
|
```typescript
|
|
// Check for existing active session
|
|
const existing = await prisma.canvassSession.findFirst({
|
|
where: { userId, status: CanvassSessionStatus.ACTIVE },
|
|
});
|
|
if (existing) {
|
|
throw new AppError(409, 'You already have an active canvass session', 'SESSION_ACTIVE');
|
|
}
|
|
|
|
// Verify cut exists
|
|
const cut = await prisma.cut.findUnique({ where: { id: data.cutId } });
|
|
if (!cut) {
|
|
throw new AppError(404, 'Cut not found', 'CUT_NOT_FOUND');
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### canvassService.endSession(sessionId, userId)
|
|
|
|
End canvass session and recalculate cut completion.
|
|
|
|
**Post-Processing:**
|
|
|
|
```typescript
|
|
// End session
|
|
await prisma.canvassSession.update({
|
|
where: { id: sessionId },
|
|
data: { status: CanvassSessionStatus.COMPLETED, endedAt: new Date() },
|
|
});
|
|
|
|
// Recalculate cut completion percentage
|
|
await this.recalculateCutCompletion(session.cutId);
|
|
```
|
|
|
|
**Cut Completion Calculation:**
|
|
|
|
```typescript
|
|
async recalculateCutCompletion(cutId: string) {
|
|
// Get all addresses in cut
|
|
const totalAddresses = await this.countAddressesInCut(cutId);
|
|
|
|
// Get visited addresses (distinct addressId from visits)
|
|
const visitedCount = await prisma.canvassVisit.findMany({
|
|
where: { address: { location: { cuts: { some: { id: cutId } } } } },
|
|
distinct: ['addressId'],
|
|
}).then(visits => visits.length);
|
|
|
|
const completionPercentage = totalAddresses > 0
|
|
? Math.round((visitedCount / totalAddresses) * 100)
|
|
: 0;
|
|
|
|
await prisma.cut.update({
|
|
where: { id: cutId },
|
|
data: { completionPercentage },
|
|
});
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### canvassService.recordVisit(userId, data)
|
|
|
|
Record visit to address with optional location update.
|
|
|
|
**Address Update Logic:**
|
|
|
|
```typescript
|
|
if (data.updateLocation && (data.outcome === VisitOutcome.CONTACTED || data.outcome === VisitOutcome.SUPPORTER)) {
|
|
await prisma.address.update({
|
|
where: { id: data.addressId },
|
|
data: {
|
|
supportLevel: data.supportLevel || undefined,
|
|
sign: data.signRequested || undefined,
|
|
signSize: data.signRequested ? data.signSize : undefined,
|
|
},
|
|
});
|
|
}
|
|
```
|
|
|
|
**Metrics:**
|
|
|
|
```typescript
|
|
recordCanvassVisit(data.outcome); // Prometheus counter
|
|
```
|
|
|
|
---
|
|
|
|
### canvassService.getWalkingRoute(cutId, userId, options)
|
|
|
|
Get optimized walking route for cut.
|
|
|
|
**Algorithm:**
|
|
|
|
```typescript
|
|
import { calculateWalkingRoute } from './canvass-route.service';
|
|
|
|
const addresses = await this.getCutLocationsForCanvass(cutId, userId);
|
|
|
|
// Filter to unvisited if requested
|
|
const unvisited = options.excludeVisited
|
|
? addresses.filter(addr => !addr.lastVisit)
|
|
: addresses;
|
|
|
|
// Calculate route using nearest-neighbor algorithm
|
|
const route = calculateWalkingRoute(
|
|
unvisited,
|
|
options.startLatitude,
|
|
options.startLongitude,
|
|
);
|
|
|
|
return route;
|
|
```
|
|
|
|
---
|
|
|
|
## Abandoned Session Cleanup
|
|
|
|
**Scheduled Task:**
|
|
|
|
Runs on API startup and every hour:
|
|
|
|
```typescript
|
|
// api/src/server.ts
|
|
async function closeAbandonedSessions() {
|
|
const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);
|
|
|
|
const result = await prisma.canvassSession.updateMany({
|
|
where: {
|
|
status: CanvassSessionStatus.ACTIVE,
|
|
startedAt: { lt: twelveHoursAgo },
|
|
},
|
|
data: {
|
|
status: CanvassSessionStatus.ABANDONED,
|
|
endedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
if (result.count > 0) {
|
|
logger.info(`Closed ${result.count} abandoned canvass sessions`);
|
|
}
|
|
}
|
|
|
|
// Run on startup
|
|
closeAbandonedSessions();
|
|
|
|
// Run every hour
|
|
setInterval(closeAbandonedSessions, 60 * 60 * 1000);
|
|
```
|
|
|
|
---
|
|
|
|
## Validation Schemas
|
|
|
|
### Record Visit Schema
|
|
|
|
```typescript
|
|
export const recordVisitSchema = z.object({
|
|
addressId: z.string().min(1),
|
|
outcome: z.nativeEnum(VisitOutcome),
|
|
supportLevel: z.nativeEnum(SupportLevel).optional(),
|
|
signRequested: z.boolean().optional().default(false),
|
|
signSize: z.string().optional(),
|
|
notes: z.string().optional(),
|
|
durationSeconds: z.number().int().optional(),
|
|
sessionId: z.string().optional(),
|
|
shiftId: z.string().optional(),
|
|
updateLocation: z.boolean().optional().default(true),
|
|
});
|
|
```
|
|
|
|
### Bulk Record Visit Schema
|
|
|
|
```typescript
|
|
export const bulkRecordVisitSchema = z.object({
|
|
locationId: z.string().min(1), // Building ID
|
|
outcome: z.enum(['NOT_HOME', 'REFUSED', 'MOVED']), // Only non-contact outcomes
|
|
notes: z.string().optional(),
|
|
sessionId: z.string().optional(),
|
|
shiftId: z.string().optional(),
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Code Examples
|
|
|
|
### Volunteer: Start Canvass Session
|
|
|
|
```typescript
|
|
import { api } from '@/lib/api';
|
|
import { message } from 'antd';
|
|
|
|
const startSession = async (cutId: string, shiftId?: string) => {
|
|
// Get current GPS position
|
|
navigator.geolocation.getCurrentPosition(async (position) => {
|
|
try {
|
|
const { data } = await api.post('/api/map/canvass/sessions', {
|
|
cutId,
|
|
shiftId,
|
|
startLatitude: position.coords.latitude,
|
|
startLongitude: position.coords.longitude,
|
|
});
|
|
|
|
message.success('Canvass session started');
|
|
console.log(`Session ID: ${data.id}`);
|
|
} catch (error: any) {
|
|
if (error.response?.status === 409) {
|
|
message.error('You already have an active session');
|
|
} else {
|
|
message.error('Failed to start session');
|
|
}
|
|
}
|
|
});
|
|
};
|
|
```
|
|
|
|
### Volunteer: Record Visit
|
|
|
|
```typescript
|
|
import { api } from '@/lib/api';
|
|
import { message } from 'antd';
|
|
|
|
const recordVisit = async (addressId: string, outcome: string, sessionId: string) => {
|
|
try {
|
|
const { data } = await api.post('/api/map/canvass/visits', {
|
|
addressId,
|
|
outcome,
|
|
supportLevel: 'LEVEL_2',
|
|
signRequested: true,
|
|
signSize: 'Large',
|
|
notes: 'Interested in volunteering',
|
|
durationSeconds: 180,
|
|
sessionId,
|
|
updateLocation: true,
|
|
});
|
|
|
|
message.success('Visit recorded');
|
|
return data;
|
|
} catch (error: any) {
|
|
if (error.response?.status === 429) {
|
|
message.error('Rate limit exceeded. Please wait a moment.');
|
|
} else {
|
|
message.error('Failed to record visit');
|
|
}
|
|
}
|
|
};
|
|
```
|
|
|
|
### Admin: Get Canvass Statistics
|
|
|
|
```typescript
|
|
import { api } from '@/lib/api';
|
|
|
|
const getStats = async () => {
|
|
const { data } = await api.get('/api/map/canvass/stats');
|
|
|
|
console.log(`Total Visits: ${data.totalVisits}`);
|
|
console.log(`Active Sessions: ${data.activeSessions}`);
|
|
console.log(`Top Volunteer: ${data.topVolunteers[0]?.name} (${data.topVolunteers[0]?.visitCount} visits)`);
|
|
|
|
return data;
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Frontend Integration
|
|
|
|
### Volunteer Portal
|
|
|
|
**VolunteerMapPage** (`admin/src/pages/volunteer/VolunteerMapPage.tsx`):
|
|
|
|
- Full-screen Leaflet map (no AppLayout)
|
|
- GPS tracking (blue dot follows volunteer)
|
|
- Location markers (color-coded by visit status)
|
|
- Walking route visualization (dashed blue line)
|
|
- Bottom sheet toolbar (floating panel)
|
|
- Visit recording form (outcome, notes, duration)
|
|
- Optimized route toggle (exclude visited addresses)
|
|
- Session timer (displays elapsed time)
|
|
|
|
**MyAssignmentsPage** (`admin/src/pages/volunteer/MyAssignmentsPage.tsx`):
|
|
|
|
- Assigned shifts table
|
|
- Cut names + completion percentage
|
|
- "Start Canvassing" button (opens map, starts session)
|
|
|
|
**MyActivityPage** (`admin/src/pages/volunteer/MyActivityPage.tsx`):
|
|
|
|
- Visit history table (paginated)
|
|
- Outcome breakdown (pie chart)
|
|
- Today's visit count vs. total
|
|
|
|
**State Management:**
|
|
|
|
```typescript
|
|
// admin/src/stores/canvass.store.ts
|
|
interface CanvassState {
|
|
session: CanvassSession | null;
|
|
locations: CanvassLocation[];
|
|
route: WalkingRoute | null;
|
|
gpsPosition: { lat: number; lng: number } | null;
|
|
selectedAddress: string | null;
|
|
showVisitRecording: boolean;
|
|
}
|
|
```
|
|
|
|
### Admin Dashboard
|
|
|
|
**CanvassDashboardPage** (`admin/src/pages/CanvassDashboardPage.tsx`):
|
|
|
|
- Statistics cards (total visits, active sessions, volunteers, completion %)
|
|
- Recent activity feed (realtime visit stream)
|
|
- Cut progress table (completionPercentage, visitCount)
|
|
- Volunteer leaderboard (sorted by visit count)
|
|
|
|
---
|
|
|
|
## Performance Considerations
|
|
|
|
**Rate Limiting:**
|
|
|
|
- Single visits: 30/min per IP (prevents spam)
|
|
- Bulk visits: 10/min per IP (stricter for building-wide operations)
|
|
- Geocoding: 10/min per IP (prevents geocoding API abuse)
|
|
|
|
**Abandoned Session Cleanup:**
|
|
|
|
- Runs hourly (low overhead)
|
|
- Only updates sessions older than 12 hours
|
|
- Prevents stale ACTIVE sessions blocking new sessions
|
|
|
|
**Walking Route Algorithm:**
|
|
|
|
- O(n²) complexity acceptable for typical cuts (<500 locations)
|
|
- Uses haversine distance (meters) for accuracy
|
|
- Pre-filters visited addresses when `excludeVisited=true`
|
|
|
|
**Cut Completion Calculation:**
|
|
|
|
- Triggered on session end (not every visit)
|
|
- Uses `distinct: ['addressId']` to count unique addresses
|
|
- Caches result in `Cut.completionPercentage` field
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### Issue: "You already have an active canvass session"
|
|
|
|
**Cause:** Volunteer forgot to end previous session
|
|
|
|
**Solution:**
|
|
|
|
- Admin: Find session in CanvassDashboardPage, manually mark as COMPLETED
|
|
- Wait for automatic cleanup (12h timeout)
|
|
- Volunteer: Navigate to session end screen and click "End Session"
|
|
|
|
### Issue: Rate limit exceeded (429) when recording visits
|
|
|
|
**Cause:** Recording visits too quickly (>30/min)
|
|
|
|
**Solution:**
|
|
|
|
- Slow down visit recording (realistic door-knocking speed: ~10-15/hr)
|
|
- Use bulk visit endpoint for buildings (NOT_HOME for entire building)
|
|
|
|
### Issue: Walking route skips some addresses
|
|
|
|
**Cause:** `excludeVisited=true` filters out already-visited addresses
|
|
|
|
**Solution:**
|
|
|
|
- Set `excludeVisited=false` to see all addresses
|
|
- Verify addresses have visits recorded (check `lastVisit` field)
|
|
|
|
### Issue: Cut completion percentage not updating
|
|
|
|
**Cause:** Completion calculated on session end, not per-visit
|
|
|
|
**Solution:**
|
|
|
|
- End canvass session to trigger recalculation
|
|
- Admin: View cut stats to verify visitCount vs. totalAddresses
|
|
|
|
---
|
|
|
|
## Related Documentation
|
|
|
|
- [Shifts Module](/v2/backend/modules/shifts.md) - Shift CRUD + signup system
|
|
- [Cuts Module](/v2/backend/modules/cuts.md) - Polygon filtering
|
|
- [Locations Module](/v2/backend/modules/locations.md) - Location management
|
|
- [Spatial Utils](/v2/backend/utilities/spatial-utils.md) - Point-in-polygon, haversine distance
|
|
- [Frontend: VolunteerMapPage](/v2/frontend/pages/volunteer/volunteer-map-page.md) - Canvassing map UI
|
|
- [Frontend: CanvassDashboardPage](/v2/frontend/pages/admin/canvass-dashboard-page.md) - Admin dashboard
|
|
- [API Reference: Canvass](/v2/api-reference/canvass.md) - Complete endpoint reference
|
|
- [Feature: Volunteer Canvassing](/v2/features/map/volunteer-canvassing.md) - Canvassing feature guide
|