Tonne of debugging - getting ready for the production builds
This commit is contained in:
95
mkdocs/docs/v2/features/map/MAP_FEATURES_STATUS.md
Normal file
95
mkdocs/docs/v2/features/map/MAP_FEATURES_STATUS.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Map Features Documentation Status
|
||||
|
||||
## Completion Summary
|
||||
|
||||
**Date**: 2026-02-13
|
||||
**Task**: Create 9 comprehensive Map feature documentation files
|
||||
**Status**: 4/9 COMPLETE (in progress)
|
||||
|
||||
## Completed Files (4053 lines)
|
||||
|
||||
1. ✅ **locations.md** (1154 lines) — Location management system
|
||||
- Building + unit architecture
|
||||
- NAR integration
|
||||
- CSV import/export
|
||||
- Geocoding integration
|
||||
- Multi-provider support
|
||||
|
||||
2. ✅ **geocoding.md** (1029 lines) — Multi-provider geocoding service
|
||||
- 6 provider fallback chain
|
||||
- Confidence scoring
|
||||
- Redis caching
|
||||
- BullMQ bulk processing
|
||||
- Provider health tracking
|
||||
|
||||
3. ✅ **cuts.md** (924 lines) — Geographic polygon overlays
|
||||
- Polygon drawing workflow
|
||||
- GeoJSON storage
|
||||
- Point-in-polygon ray-casting
|
||||
- Cut categories
|
||||
- Completion tracking
|
||||
|
||||
4. ✅ **shifts.md** (946 lines) — Volunteer shift management
|
||||
- Shift scheduling
|
||||
- Capacity management
|
||||
- Public signup
|
||||
- TEMP user creation
|
||||
- Email confirmations
|
||||
|
||||
## Remaining Files (5)
|
||||
|
||||
5. 🚧 **canvassing.md** — Canvassing session system
|
||||
- Session lifecycle
|
||||
- Visit recording
|
||||
- Walking route algorithm
|
||||
- GPS integration
|
||||
- Volunteer + admin workflows
|
||||
|
||||
6. 🚧 **tracking.md** — GPS tracking system
|
||||
- TrackingSession model
|
||||
- TrackPoint recording
|
||||
- Distance calculation
|
||||
- Route visualization
|
||||
- Live volunteer tracking
|
||||
|
||||
7. 🚧 **walk-sheets.md** — Printable walk sheets + QR codes
|
||||
- MapSettings configuration
|
||||
- QR code generation
|
||||
- Walk sheet layout
|
||||
- Cut export
|
||||
- Browser print API
|
||||
|
||||
8. 🚧 **data-quality.md** — Geocoding quality dashboard
|
||||
- Confidence metrics
|
||||
- Provider success rate
|
||||
- Ungeocoded locations
|
||||
- Low-confidence alerts
|
||||
- Duplicate detection
|
||||
|
||||
9. 🚧 **nar-import.md** — NAR 2025 electoral data import
|
||||
- NAR format support
|
||||
- Server-side streaming
|
||||
- Address + Location join
|
||||
- Lambert coordinate conversion
|
||||
- Province code mapping
|
||||
|
||||
## Next Steps
|
||||
|
||||
Continue creating remaining 5 files following the established 12-section structure:
|
||||
|
||||
1. Overview
|
||||
2. Architecture (Mermaid diagram)
|
||||
3. Database Models
|
||||
4. API Endpoints
|
||||
5. Configuration
|
||||
6. Admin Workflow
|
||||
7. Public Workflow (if applicable)
|
||||
8. Volunteer Workflow (if applicable)
|
||||
9. Code Examples
|
||||
10. Troubleshooting
|
||||
11. Performance Considerations
|
||||
12. Related Documentation
|
||||
|
||||
**Target**: 6,000-9,000 total lines across all 9 files (~670-1000 lines per file)
|
||||
**Current**: 4,053 lines (4 files)
|
||||
**Remaining**: ~2,950-4,950 lines (5 files)
|
||||
1020
mkdocs/docs/v2/features/map/canvassing.md
Normal file
1020
mkdocs/docs/v2/features/map/canvassing.md
Normal file
File diff suppressed because it is too large
Load Diff
924
mkdocs/docs/v2/features/map/cuts.md
Normal file
924
mkdocs/docs/v2/features/map/cuts.md
Normal file
@@ -0,0 +1,924 @@
|
||||
# Geographic Polygon Overlays (Cuts)
|
||||
|
||||
## Overview
|
||||
|
||||
The cuts system provides polygon-based geographic organizing using customizable map overlays. Cuts enable campaigns to divide territories into canvassing zones, track completion progress, and assign volunteers to specific areas.
|
||||
|
||||
**Key Capabilities:**
|
||||
|
||||
- **Polygon Drawing**: Click-to-draw custom polygons on Leaflet maps
|
||||
- **GeoJSON Storage**: Store complex polygons with coordinate precision
|
||||
- **Spatial Queries**: Point-in-polygon filtering using ray-casting algorithm
|
||||
- **Cut Categories**: CUSTOM, WARD, NEIGHBORHOOD, DISTRICT classification
|
||||
- **Visual Customization**: Configurable colors and opacity for map overlays
|
||||
- **Bounds Calculation**: Auto-calculate bounding box from polygon coordinates
|
||||
- **Completion Tracking**: Track canvassing progress by cut
|
||||
- **Shift Assignment**: Link shifts to cuts for volunteer scheduling
|
||||
- **Export Filtering**: Generate walk sheets for specific cuts
|
||||
|
||||
**Use Cases:**
|
||||
|
||||
- Electoral district mapping (wards, polling divisions)
|
||||
- Canvassing zone organization
|
||||
- Neighborhood targeting
|
||||
- Volunteer territory assignment
|
||||
- Walk sheet generation by area
|
||||
- Progress tracking by geographic zone
|
||||
- Multi-volunteer coordination
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Admin User] -->|Draws Polygon| B[CutDrawingMode]
|
||||
B -->|Click Vertices| C[Leaflet Map]
|
||||
C -->|Auto-Close Detection| D[GeoJSON Polygon]
|
||||
D -->|POST /api/map/cuts| E[Cuts Service]
|
||||
E -->|Calculate Bounds| F[Spatial Utils]
|
||||
F -->|Save| G[(Cut Model)]
|
||||
|
||||
H[Public Map] -->|Load Cuts| I[GET /api/public/map/cuts]
|
||||
I -->|Return GeoJSON| E
|
||||
E -->|Query| G
|
||||
I -->|Render| J[CutOverlays Component]
|
||||
|
||||
K[Canvass Session] -->|Start in Cut| L[Canvass Service]
|
||||
L -->|Load Addresses| M[Locations Service]
|
||||
M -->|Point-in-Polygon| F
|
||||
F -->|Filter| N[(Location Model)]
|
||||
|
||||
O[Shift] -->|Assigned to Cut| G
|
||||
G -->|1:N| O
|
||||
|
||||
P[Export Locations] -->|Filter by Cut| M
|
||||
M -->|Query Polygon| F
|
||||
|
||||
style G fill:#e1f5ff
|
||||
style N fill:#e1f5ff
|
||||
style O fill:#e1f5ff
|
||||
```
|
||||
|
||||
**Flow Description:**
|
||||
|
||||
1. **Admin draws cut** → Click vertices on map, auto-close detection, generate GeoJSON
|
||||
2. **Save cut** → Calculate bounds from coordinates, store polygon in database
|
||||
3. **Public map loads** → Query public cuts, render as colored overlays with opacity
|
||||
4. **Canvass session starts** → Load addresses within cut polygon using ray-casting
|
||||
5. **Shift assignment** → Link shift to cut for volunteer scheduling
|
||||
6. **Export locations** → Filter by cut polygon to generate walk sheet
|
||||
|
||||
## Database Models
|
||||
|
||||
### Cut Model
|
||||
|
||||
See [Cut Model Documentation](../../database/models/map.md#cut-model) for full schema.
|
||||
|
||||
**Key Fields:**
|
||||
|
||||
- `name`: Cut display name (e.g., "Ward 5 - Downtown")
|
||||
- `description`: Free-text notes about the cut
|
||||
- `geojson`: Polygon coordinates in GeoJSON format (TEXT field)
|
||||
- `bounds`: Auto-calculated bounding box `{minLat, maxLat, minLng, maxLng}` (JSON)
|
||||
- `color`: Hex color for map overlay (default: `#3498db`)
|
||||
- `opacity`: Opacity 0.0-1.0 for map rendering (default: 0.3)
|
||||
- `category`: CUSTOM | WARD | NEIGHBORHOOD | DISTRICT
|
||||
- `isPublic`: Show on public map
|
||||
- `isOfficial`: Official electoral boundary (prevents accidental deletion)
|
||||
- `showLocations`: Show location markers within cut on map
|
||||
- `exportEnabled`: Allow walk sheet export for this cut
|
||||
- `assignedTo`: Free-text assigned volunteer/team name
|
||||
- `completionPercentage`: Auto-calculated canvassing progress (0-100)
|
||||
|
||||
**GeoJSON Format:**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[-75.6972, 45.4215],
|
||||
[-75.6980, 45.4220],
|
||||
[-75.6960, 45.4230],
|
||||
[-75.6950, 45.4225],
|
||||
[-75.6972, 45.4215]
|
||||
]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Bounds Format:**
|
||||
|
||||
```json
|
||||
{
|
||||
"minLat": 45.4215,
|
||||
"maxLat": 45.4230,
|
||||
"minLng": -75.6980,
|
||||
"maxLng": -75.6950
|
||||
}
|
||||
```
|
||||
|
||||
**Related Models:**
|
||||
|
||||
- [Shift](../../database/models/map.md#shift-model) — Volunteer shifts assigned to cut
|
||||
- [CanvassSession](../../database/models/canvass.md#canvasssession-model) — Canvassing within cut
|
||||
- [Location](../../database/models/map.md#location-model) — Filtered by cut polygon
|
||||
|
||||
## API Endpoints
|
||||
|
||||
See [Cuts Backend Module Documentation](../../backend/modules/map/cuts.md) for full API reference.
|
||||
|
||||
**Admin Endpoints:**
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| GET | `/api/map/cuts` | MAP_ADMIN | List cuts with pagination, search, category filter |
|
||||
| GET | `/api/map/cuts/stats` | MAP_ADMIN | Get cut statistics (total, by category) |
|
||||
| GET | `/api/map/cuts/:id` | MAP_ADMIN | Get cut details |
|
||||
| POST | `/api/map/cuts` | MAP_ADMIN | Create new cut with polygon |
|
||||
| PATCH | `/api/map/cuts/:id` | MAP_ADMIN | Update cut |
|
||||
| DELETE | `/api/map/cuts/:id` | MAP_ADMIN | Delete cut (blocked if `isOfficial=true`) |
|
||||
| GET | `/api/map/cuts/:id/locations` | MAP_ADMIN | Get locations within cut polygon |
|
||||
| GET | `/api/map/cuts/:id/progress` | MAP_ADMIN | Get canvassing progress for cut |
|
||||
|
||||
**Public Endpoints:**
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| GET | `/api/public/map/cuts` | None | List public cuts (isPublic=true) |
|
||||
| GET | `/api/public/map/cuts/:id` | None | Get public cut details |
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
No specific environment variables for cuts. Uses standard database and map settings.
|
||||
|
||||
### Cut Category Enum
|
||||
|
||||
```typescript
|
||||
enum CutCategory {
|
||||
CUSTOM // User-defined boundary
|
||||
WARD // Municipal ward boundary
|
||||
NEIGHBORHOOD // Neighborhood association boundary
|
||||
DISTRICT // Electoral district boundary
|
||||
}
|
||||
```
|
||||
|
||||
### Default Values
|
||||
|
||||
| Field | Default | Description |
|
||||
|-------|---------|-------------|
|
||||
| `color` | `#3498db` | Blue color for overlay |
|
||||
| `opacity` | `0.3` | 30% opacity (transparent) |
|
||||
| `isPublic` | `false` | Hidden from public map |
|
||||
| `isOfficial` | `false` | Can be deleted by admin |
|
||||
| `showLocations` | `true` | Show location markers within cut |
|
||||
| `exportEnabled` | `true` | Allow walk sheet export |
|
||||
| `completionPercentage` | `0` | Auto-updated by canvass service |
|
||||
|
||||
## Admin Workflow
|
||||
|
||||
### Creating a Cut
|
||||
|
||||
**Step 1: Navigate to Cuts Page**
|
||||
|
||||
Navigate to **Map → Cuts** in the admin sidebar.
|
||||
|
||||
![CutsPage Screenshot Placeholder]
|
||||
|
||||
**Step 2: Open Drawing Tab**
|
||||
|
||||
Click **Drawing** tab to switch to map drawing mode.
|
||||
|
||||
**Step 3: Activate Drawing Mode**
|
||||
|
||||
Click **Draw Cut** button in the map controls. Map cursor changes to crosshair.
|
||||
|
||||
**Step 4: Click Vertices**
|
||||
|
||||
Click on the map to place polygon vertices:
|
||||
|
||||
- **First Click**: Start polygon
|
||||
- **Additional Clicks**: Add vertices
|
||||
- **Auto-Close**: When cursor near start point (within 10px), polygon auto-closes
|
||||
|
||||
**Step 5: Configure Cut**
|
||||
|
||||
Fill in the cut form (right sidebar):
|
||||
|
||||
- **Name**: "Ward 5 - Downtown"
|
||||
- **Description**: "Central business district and residential blocks"
|
||||
- **Category**: WARD
|
||||
- **Color**: Choose from color picker (default: blue)
|
||||
- **Opacity**: Slider 0-100 (default: 30%)
|
||||
- **Is Public**: Toggle to show on public map
|
||||
- **Is Official**: Toggle to prevent accidental deletion
|
||||
|
||||
**Step 6: Save Cut**
|
||||
|
||||
Click **Save Cut**. The system will:
|
||||
|
||||
1. Generate GeoJSON from vertices
|
||||
2. Calculate bounding box
|
||||
3. Save to database
|
||||
4. Render polygon on map with configured color/opacity
|
||||
|
||||
### Editing a Cut
|
||||
|
||||
**Step 1: Select Cut**
|
||||
|
||||
On **Table** tab, click **Edit** button for a cut.
|
||||
|
||||
**Step 2: Update Fields**
|
||||
|
||||
Modify cut properties:
|
||||
|
||||
- **Name/Description**: Update text fields
|
||||
- **Color/Opacity**: Adjust visual appearance
|
||||
- **Category**: Change classification
|
||||
- **Public/Official**: Toggle flags
|
||||
|
||||
**Step 3: Re-Draw Polygon (Optional)**
|
||||
|
||||
To change polygon shape:
|
||||
|
||||
1. Switch to **Drawing** tab
|
||||
2. Click **Edit Cut** button
|
||||
3. Delete old vertices (click vertices to remove)
|
||||
4. Add new vertices
|
||||
5. Auto-close polygon
|
||||
|
||||
**Step 4: Save Changes**
|
||||
|
||||
Click **Update** to save changes. Bounds are auto-recalculated if polygon changed.
|
||||
|
||||
### Viewing Locations in Cut
|
||||
|
||||
**Step 1: Select Cut**
|
||||
|
||||
Click cut row in table to select.
|
||||
|
||||
**Step 2: Click "View Locations"**
|
||||
|
||||
Click **View Locations** button.
|
||||
|
||||
**Step 3: View Filtered Table**
|
||||
|
||||
System displays locations within cut polygon:
|
||||
|
||||
- **Point-in-Polygon**: Uses ray-casting algorithm to filter
|
||||
- **Count**: Number of locations within cut
|
||||
- **Support Breakdown**: Count by support level
|
||||
|
||||
**Step 4: Export Locations**
|
||||
|
||||
Click **Export CSV** to download locations for walk sheet generation.
|
||||
|
||||
### Assigning Cut to Shift
|
||||
|
||||
**Step 1: Create/Edit Shift**
|
||||
|
||||
On **Map → Shifts** page, create or edit a shift.
|
||||
|
||||
**Step 2: Select Cut**
|
||||
|
||||
In shift form, choose cut from **Cut** dropdown.
|
||||
|
||||
**Step 3: Save Shift**
|
||||
|
||||
Shift is now linked to cut. Volunteers will see cut name on shift details.
|
||||
|
||||
### Tracking Cut Completion
|
||||
|
||||
**Step 1: View Cut Progress**
|
||||
|
||||
On CutsPage, click **Progress** button for a cut.
|
||||
|
||||
**Step 2: View Metrics**
|
||||
|
||||
System displays:
|
||||
|
||||
- **Completion Percentage**: Auto-calculated from canvass visits
|
||||
- **Total Addresses**: Count of addresses within cut
|
||||
- **Visited**: Count of addresses with CanvassVisit records
|
||||
- **Outstanding**: Remaining addresses to visit
|
||||
|
||||
**Step 3: View Canvass Activity**
|
||||
|
||||
Table shows recent canvass visits within cut:
|
||||
|
||||
- **Volunteer Name**: Who visited
|
||||
- **Visit Date**: When visited
|
||||
- **Outcome**: Visit result (SPOKE_WITH, NOT_HOME, etc.)
|
||||
- **Support Level**: Updated support level (if applicable)
|
||||
|
||||
## Public Workflow
|
||||
|
||||
**Public users can view cut overlays on the interactive map.**
|
||||
|
||||
**Step 1: Navigate to Public Map**
|
||||
|
||||
Visit `/map` (no authentication required).
|
||||
|
||||
**Step 2: Toggle Cut Overlays**
|
||||
|
||||
Click **Cuts** button in map controls to open overlay panel.
|
||||
|
||||
**Step 3: Select Cuts**
|
||||
|
||||
Check/uncheck cuts to show/hide on map:
|
||||
|
||||
- **Color Legend**: Shows cut name and color
|
||||
- **Opacity**: Semi-transparent overlays don't obscure markers
|
||||
- **Multiple Cuts**: Show multiple cuts simultaneously
|
||||
|
||||
**Step 4: View Cut Details**
|
||||
|
||||
Click on a cut polygon to view:
|
||||
|
||||
- **Cut Name**: Displayed in popup
|
||||
- **Category**: Ward, Neighborhood, etc.
|
||||
- **Assigned To**: Volunteer/team name (if configured)
|
||||
|
||||
## Volunteer Workflow
|
||||
|
||||
**Volunteers interact with cuts via shift assignments.**
|
||||
|
||||
**Step 1: View Assigned Shifts**
|
||||
|
||||
On **Volunteer → My Assignments** page, view shifts with cut assignments.
|
||||
|
||||
**Step 2: Start Canvass Session**
|
||||
|
||||
Click **Start Canvass** on a shift. Redirects to `/volunteer/canvass/:cutId`.
|
||||
|
||||
**Step 3: View Cut on Map**
|
||||
|
||||
Full-screen map shows:
|
||||
|
||||
- **Cut Polygon**: Highlighted boundary
|
||||
- **Locations Within Cut**: Filtered to cut polygon only
|
||||
- **Walking Route**: Optimal route through cut locations
|
||||
|
||||
See [Canvassing Documentation](./canvassing.md) for full volunteer workflow.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Cut Service Create (Backend)
|
||||
|
||||
```typescript
|
||||
// api/src/modules/map/cuts/cuts.service.ts
|
||||
import { parseGeoJsonPolygon, calculateBounds } from '../../../utils/spatial';
|
||||
|
||||
async create(data: CreateCutInput, userId: string) {
|
||||
// Auto-calculate bounds from geojson if not provided
|
||||
let boundsStr = data.bounds;
|
||||
if (!boundsStr) {
|
||||
try {
|
||||
const rings = parseGeoJsonPolygon(data.geojson);
|
||||
const allCoords = rings.flat();
|
||||
const bounds = calculateBounds(allCoords);
|
||||
boundsStr = JSON.stringify(bounds);
|
||||
} catch {
|
||||
// Bounds calculation optional
|
||||
}
|
||||
}
|
||||
|
||||
const cut = await prisma.cut.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
color: data.color,
|
||||
opacity: data.opacity,
|
||||
category: data.category,
|
||||
isPublic: data.isPublic,
|
||||
isOfficial: data.isOfficial,
|
||||
geojson: data.geojson,
|
||||
bounds: boundsStr,
|
||||
showLocations: data.showLocations,
|
||||
exportEnabled: data.exportEnabled,
|
||||
assignedTo: data.assignedTo,
|
||||
createdByUserId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
return cut;
|
||||
}
|
||||
```
|
||||
|
||||
### Bounds Calculation (Backend)
|
||||
|
||||
```typescript
|
||||
// api/src/utils/spatial.ts
|
||||
export function calculateBounds(coordinates: number[][]): {
|
||||
minLat: number;
|
||||
maxLat: number;
|
||||
minLng: number;
|
||||
maxLng: number;
|
||||
} {
|
||||
let minLat = Infinity;
|
||||
let maxLat = -Infinity;
|
||||
let minLng = Infinity;
|
||||
let maxLng = -Infinity;
|
||||
|
||||
for (const coord of coordinates) {
|
||||
const lng = coord[0]!;
|
||||
const lat = coord[1]!;
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
if (lng < minLng) minLng = lng;
|
||||
if (lng > maxLng) maxLng = lng;
|
||||
}
|
||||
|
||||
return { minLat, maxLat, minLng, maxLng };
|
||||
}
|
||||
```
|
||||
|
||||
### Point-in-Polygon Filter (Backend)
|
||||
|
||||
```typescript
|
||||
// api/src/modules/map/cuts/cuts.service.ts
|
||||
import { isPointInPolygon, parseGeoJsonPolygon } from '../../../utils/spatial';
|
||||
|
||||
async getLocationsInCut(cutId: string) {
|
||||
const cut = await prisma.cut.findUnique({
|
||||
where: { id: cutId },
|
||||
select: { geojson: true },
|
||||
});
|
||||
|
||||
if (!cut?.geojson) {
|
||||
throw new AppError(404, 'Cut not found', 'CUT_NOT_FOUND');
|
||||
}
|
||||
|
||||
// Get all locations (or use bounds for optimization)
|
||||
const locations = await prisma.location.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
latitude: true,
|
||||
longitude: true,
|
||||
address: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Parse polygon coordinates
|
||||
const polygons = parseGeoJsonPolygon(cut.geojson);
|
||||
|
||||
// Filter locations using ray-casting algorithm
|
||||
const filtered = locations.filter((loc) => {
|
||||
const lat = Number(loc.latitude);
|
||||
const lng = Number(loc.longitude);
|
||||
return polygons.some((poly) => isPointInPolygon(lat, lng, poly));
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}
|
||||
```
|
||||
|
||||
### Ray-Casting Algorithm (Backend)
|
||||
|
||||
```typescript
|
||||
// api/src/utils/spatial.ts
|
||||
export function isPointInPolygon(
|
||||
lat: number,
|
||||
lng: number,
|
||||
polygonCoords: number[][]
|
||||
): boolean {
|
||||
let inside = false;
|
||||
for (let i = 0, j = polygonCoords.length - 1; i < polygonCoords.length; j = i++) {
|
||||
const xi = polygonCoords[i]![1]!; // lat
|
||||
const yi = polygonCoords[i]![0]!; // lng
|
||||
const xj = polygonCoords[j]![1]!;
|
||||
const yj = polygonCoords[j]![0]!;
|
||||
|
||||
const intersect = ((yi > lng) !== (yj > lng)) &&
|
||||
(lat < (xj - xi) * (lng - yi) / (yj - yi) + xi);
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
```
|
||||
|
||||
### Cut Drawing Mode (Frontend)
|
||||
|
||||
```typescript
|
||||
// admin/src/components/map/CutDrawingMode.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useMapEvents } from 'react-leaflet';
|
||||
import type { LatLng } from 'leaflet';
|
||||
|
||||
interface CutDrawingModeProps {
|
||||
onPolygonComplete: (vertices: LatLng[]) => void;
|
||||
}
|
||||
|
||||
export default function CutDrawingMode({ onPolygonComplete }: CutDrawingModeProps) {
|
||||
const [vertices, setVertices] = useState<LatLng[]>([]);
|
||||
const [isDrawing, setIsDrawing] = useState(true);
|
||||
|
||||
useMapEvents({
|
||||
click(e) {
|
||||
if (!isDrawing) return;
|
||||
|
||||
const newVertex = e.latlng;
|
||||
|
||||
// Auto-close detection: if click near first vertex (within 10px)
|
||||
if (vertices.length >= 3) {
|
||||
const firstVertex = vertices[0]!;
|
||||
const map = e.target;
|
||||
const firstPoint = map.latLngToContainerPoint(firstVertex);
|
||||
const newPoint = map.latLngToContainerPoint(newVertex);
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(firstPoint.x - newPoint.x, 2) +
|
||||
Math.pow(firstPoint.y - newPoint.y, 2)
|
||||
);
|
||||
|
||||
if (distance < 10) {
|
||||
// Auto-close polygon
|
||||
setIsDrawing(false);
|
||||
onPolygonComplete(vertices);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Add vertex
|
||||
setVertices([...vertices, newVertex]);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Render temporary polygon while drawing */}
|
||||
{vertices.length >= 2 && (
|
||||
<Polygon positions={vertices} pathOptions={{ color: '#3498db', opacity: 0.5 }} />
|
||||
)}
|
||||
{/* Render vertex markers */}
|
||||
{vertices.map((v, i) => (
|
||||
<CircleMarker
|
||||
key={i}
|
||||
center={v}
|
||||
radius={5}
|
||||
pathOptions={{ color: '#e74c3c', fillColor: '#e74c3c', fillOpacity: 1 }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Cut Overlays Rendering (Frontend)
|
||||
|
||||
```typescript
|
||||
// admin/src/components/map/CutOverlays.tsx
|
||||
import { Polygon, Popup } from 'react-leaflet';
|
||||
import type { Cut } from '@/types/api';
|
||||
|
||||
interface CutOverlaysProps {
|
||||
cuts: Cut[];
|
||||
visibleCutIds: string[];
|
||||
}
|
||||
|
||||
export default function CutOverlays({ cuts, visibleCutIds }: CutOverlaysProps) {
|
||||
return (
|
||||
<>
|
||||
{cuts
|
||||
.filter((cut) => visibleCutIds.includes(cut.id))
|
||||
.map((cut) => {
|
||||
const geojson = JSON.parse(cut.geojson);
|
||||
// GeoJSON uses [lng, lat], Leaflet uses [lat, lng]
|
||||
const positions = geojson.coordinates[0].map(([lng, lat]: number[]) => [lat, lng]);
|
||||
|
||||
return (
|
||||
<Polygon
|
||||
key={cut.id}
|
||||
positions={positions}
|
||||
pathOptions={{
|
||||
color: cut.color,
|
||||
fillColor: cut.color,
|
||||
fillOpacity: cut.opacity,
|
||||
weight: 2,
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div>
|
||||
<strong>{cut.name}</strong>
|
||||
<br />
|
||||
{cut.category}
|
||||
{cut.assignedTo && (
|
||||
<>
|
||||
<br />
|
||||
Assigned to: {cut.assignedTo}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Polygon>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Convert Leaflet Polygon to GeoJSON (Frontend)
|
||||
|
||||
```typescript
|
||||
// admin/src/pages/CutsPage.tsx
|
||||
const handleSaveCut = async (vertices: LatLng[]) => {
|
||||
// Convert Leaflet [lat, lng] to GeoJSON [lng, lat]
|
||||
const coordinates = vertices.map((v) => [v.lng, v.lat]);
|
||||
|
||||
// Close polygon (first vertex === last vertex)
|
||||
coordinates.push(coordinates[0]!);
|
||||
|
||||
const geojson = {
|
||||
type: 'Polygon',
|
||||
coordinates: [coordinates],
|
||||
};
|
||||
|
||||
try {
|
||||
const { data } = await api.post<Cut>('/map/cuts', {
|
||||
name: cutName,
|
||||
description: cutDescription,
|
||||
geojson: JSON.stringify(geojson),
|
||||
color: cutColor,
|
||||
opacity: cutOpacity,
|
||||
category: cutCategory,
|
||||
isPublic: isPublic,
|
||||
isOfficial: isOfficial,
|
||||
});
|
||||
|
||||
message.success('Cut created');
|
||||
fetchCuts();
|
||||
} catch (error) {
|
||||
message.error('Failed to create cut');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: Polygon Not Closing
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Clicking near start point doesn't auto-close polygon
|
||||
- Polygon remains open after many vertices
|
||||
- "Save Cut" button disabled
|
||||
|
||||
**Causes:**
|
||||
|
||||
- Auto-close distance threshold too small
|
||||
- Mouse click precision issues on mobile
|
||||
- Map zoom level affecting pixel distance calculation
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Increase auto-close threshold**:
|
||||
|
||||
```typescript
|
||||
// admin/src/components/map/CutDrawingMode.tsx
|
||||
const AUTO_CLOSE_DISTANCE_PX = 15; // Was 10, increase to 15
|
||||
|
||||
if (distance < AUTO_CLOSE_DISTANCE_PX) {
|
||||
// Auto-close polygon
|
||||
}
|
||||
```
|
||||
|
||||
2. **Manual close button**:
|
||||
|
||||
Add explicit "Close Polygon" button for mobile users:
|
||||
|
||||
```typescript
|
||||
<Button onClick={() => {
|
||||
if (vertices.length >= 3) {
|
||||
onPolygonComplete(vertices);
|
||||
}
|
||||
}}>
|
||||
Close Polygon
|
||||
</Button>
|
||||
```
|
||||
|
||||
### Issue: Point-in-Polygon Returns Wrong Results
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Locations outside cut polygon included in canvass session
|
||||
- Locations inside cut polygon excluded
|
||||
- Export CSV missing locations
|
||||
|
||||
**Causes:**
|
||||
|
||||
- Coordinate order mismatch (GeoJSON [lng, lat] vs Leaflet [lat, lng])
|
||||
- Polygon not properly closed (first vertex !== last vertex)
|
||||
- Ray-casting algorithm bug with edge cases
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Verify coordinate order**:
|
||||
|
||||
```typescript
|
||||
// GeoJSON uses [lng, lat]
|
||||
const geojson = {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[-75.6972, 45.4215], // [lng, lat]
|
||||
[-75.6980, 45.4220],
|
||||
// ...
|
||||
]
|
||||
]
|
||||
};
|
||||
|
||||
// Leaflet uses [lat, lng]
|
||||
<Polygon positions={[[45.4215, -75.6972], [45.4220, -75.6980]]} />
|
||||
```
|
||||
|
||||
2. **Verify polygon closure**:
|
||||
|
||||
```sql
|
||||
-- Check if polygon is properly closed
|
||||
SELECT id, name,
|
||||
geojson::json->'coordinates'->0->0 as first_vertex,
|
||||
geojson::json->'coordinates'->0->-1 as last_vertex
|
||||
FROM "Cut"
|
||||
WHERE id = 'YOUR_CUT_ID';
|
||||
|
||||
-- First and last should be identical
|
||||
```
|
||||
|
||||
3. **Test with known points**:
|
||||
|
||||
```bash
|
||||
# Test point-in-polygon directly
|
||||
curl -X POST http://localhost:4000/api/map/cuts/YOUR_CUT_ID/test-point \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"latitude":45.4220,"longitude":-75.6975}'
|
||||
```
|
||||
|
||||
### Issue: Cut Rendering Performance Slow
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Map lags when rendering multiple cuts
|
||||
- Browser freezes with >10 cuts visible
|
||||
- Polygon rendering takes >2 seconds
|
||||
|
||||
**Causes:**
|
||||
|
||||
- Too many polygon vertices (complex boundaries)
|
||||
- Multiple cut overlays rendered simultaneously
|
||||
- No polygon simplification
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Simplify complex polygons**:
|
||||
|
||||
Use Turf.js simplify algorithm to reduce vertices:
|
||||
|
||||
```typescript
|
||||
import * as turf from '@turf/turf';
|
||||
|
||||
const simplified = turf.simplify(polygon, {
|
||||
tolerance: 0.0001, // Adjust based on zoom level
|
||||
highQuality: true
|
||||
});
|
||||
```
|
||||
|
||||
2. **Lazy render cuts**:
|
||||
|
||||
Only render cuts within current map bounds:
|
||||
|
||||
```typescript
|
||||
const visibleCuts = cuts.filter((cut) => {
|
||||
const bounds = JSON.parse(cut.bounds);
|
||||
const mapBounds = map.getBounds();
|
||||
return mapBounds.intersects([
|
||||
[bounds.minLat, bounds.minLng],
|
||||
[bounds.maxLat, bounds.maxLng]
|
||||
]);
|
||||
});
|
||||
```
|
||||
|
||||
3. **Use Canvas renderer**:
|
||||
|
||||
For large polygons, use Leaflet Canvas renderer instead of SVG:
|
||||
|
||||
```typescript
|
||||
<Polygon
|
||||
positions={positions}
|
||||
renderer={L.canvas()}
|
||||
pathOptions={{ color: cut.color }}
|
||||
/>
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Spatial Query Optimization
|
||||
|
||||
**Bounds Pre-Filter:**
|
||||
|
||||
Always pre-filter by bounding box before point-in-polygon:
|
||||
|
||||
```typescript
|
||||
async getLocationsInCut(cutId: string) {
|
||||
const cut = await prisma.cut.findUnique({ where: { id: cutId } });
|
||||
const bounds = JSON.parse(cut.bounds);
|
||||
|
||||
// Pre-filter by bounds (fast, uses index)
|
||||
const candidates = await prisma.location.findMany({
|
||||
where: {
|
||||
latitude: {
|
||||
gte: new Prisma.Decimal(bounds.minLat),
|
||||
lte: new Prisma.Decimal(bounds.maxLat),
|
||||
},
|
||||
longitude: {
|
||||
gte: new Prisma.Decimal(bounds.minLng),
|
||||
lte: new Prisma.Decimal(bounds.maxLng),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Then apply point-in-polygon (slower, but fewer candidates)
|
||||
const polygons = parseGeoJsonPolygon(cut.geojson);
|
||||
return candidates.filter((loc) => {
|
||||
const lat = Number(loc.latitude);
|
||||
const lng = Number(loc.longitude);
|
||||
return polygons.some((poly) => isPointInPolygon(lat, lng, poly));
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Performance Impact:**
|
||||
|
||||
- **Without bounds pre-filter**: 10,000 locations → 10,000 point-in-polygon checks
|
||||
- **With bounds pre-filter**: 10,000 locations → 500 candidates → 500 point-in-polygon checks (20x faster)
|
||||
|
||||
### Polygon Simplification
|
||||
|
||||
**Reduce Vertices for Large Cuts:**
|
||||
|
||||
Use Douglas-Peucker algorithm to simplify polygons while preserving shape:
|
||||
|
||||
```typescript
|
||||
import * as turf from '@turf/turf';
|
||||
|
||||
function simplifyPolygon(geojson: string, tolerance: number = 0.0001): string {
|
||||
const polygon = JSON.parse(geojson);
|
||||
const simplified = turf.simplify(polygon, { tolerance, highQuality: true });
|
||||
return JSON.stringify(simplified);
|
||||
}
|
||||
|
||||
// Usage: simplify when importing official boundaries (e.g., electoral districts)
|
||||
const simplifiedGeojson = simplifyPolygon(officialBoundary, 0.0005);
|
||||
```
|
||||
|
||||
**Tolerance Guidelines:**
|
||||
|
||||
- **0.00001**: High precision (±1m), use for small neighborhoods
|
||||
- **0.0001**: Medium precision (±10m), use for wards
|
||||
- **0.001**: Low precision (±100m), use for large districts
|
||||
|
||||
### Caching Cut Queries
|
||||
|
||||
**Cache Frequently Used Cuts:**
|
||||
|
||||
```typescript
|
||||
// Cache cut polygons in Redis for fast repeated queries
|
||||
const CACHE_KEY = `CUT_POLYGON:${cutId}`;
|
||||
const cached = await redis.get(CACHE_KEY);
|
||||
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
|
||||
const cut = await prisma.cut.findUnique({ where: { id: cutId } });
|
||||
await redis.setex(CACHE_KEY, 3600, JSON.stringify(cut)); // 1 hour TTL
|
||||
|
||||
return cut;
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
**Backend Modules:**
|
||||
|
||||
- [Cuts Backend Module](../../backend/modules/map/cuts.md) — API implementation
|
||||
- [Spatial Utils](../../backend/modules/utils/spatial.md) — Point-in-polygon algorithms
|
||||
- [Locations Service](../../backend/modules/map/locations.md) — Spatial filtering
|
||||
|
||||
**Frontend Pages:**
|
||||
|
||||
- [CutsPage](../../frontend/pages/admin/cuts-page.md) — Admin CRUD interface
|
||||
- [CutDrawingMode](../../frontend/components/cut-drawing-mode.md) — Polygon drawing
|
||||
- [CutOverlays](../../frontend/components/cut-overlays.md) — Map rendering
|
||||
|
||||
**Database:**
|
||||
|
||||
- [Cut Model](../../database/models/map.md#cut-model) — Cut schema
|
||||
- [Spatial Queries](../../database/queries.md#spatial-queries) — Optimization tips
|
||||
|
||||
**Features:**
|
||||
|
||||
- [Locations](./locations.md) — Location filtering by cut
|
||||
- [Shifts](./shifts.md) — Shift assignment to cuts
|
||||
- [Canvassing](./canvassing.md) — Canvassing within cut boundaries
|
||||
- [Walk Sheets](./walk-sheets.md) — Export locations by cut
|
||||
1843
mkdocs/docs/v2/features/map/data-quality.md
Normal file
1843
mkdocs/docs/v2/features/map/data-quality.md
Normal file
File diff suppressed because it is too large
Load Diff
1029
mkdocs/docs/v2/features/map/geocoding.md
Normal file
1029
mkdocs/docs/v2/features/map/geocoding.md
Normal file
File diff suppressed because it is too large
Load Diff
353
mkdocs/docs/v2/features/map/index.md
Normal file
353
mkdocs/docs/v2/features/map/index.md
Normal file
@@ -0,0 +1,353 @@
|
||||
# Map Module
|
||||
|
||||
The Map module provides comprehensive location management, geographic organization, volunteer coordination, and door-to-door canvassing capabilities. It combines GIS features with volunteer management for effective ground campaigns.
|
||||
|
||||
## Overview
|
||||
|
||||
The Map module consists of ten integrated components:
|
||||
|
||||
1. **[Locations](locations.md)** - Location database with geocoding
|
||||
2. **[Geocoding](geocoding.md)** - Multi-provider address → coordinate conversion
|
||||
3. **[NAR Import](nar-import.md)** - Canadian electoral data import
|
||||
4. **[Cuts](cuts.md)** - Geographic polygon organization
|
||||
5. **[Shifts](shifts.md)** - Volunteer shift scheduling
|
||||
6. **[Canvassing](canvassing.md)** - Door-to-door canvassing system
|
||||
7. **[Tracking](tracking.md)** - GPS tracking sessions
|
||||
8. **[Walk Sheets](walk-sheets.md)** - Printable canvass materials
|
||||
9. **[Data Quality](data-quality.md)** - Geocoding quality monitoring
|
||||
10. **Map Features Status** - Feature completion tracking
|
||||
|
||||
## Features
|
||||
|
||||
### Location Management
|
||||
|
||||
- Location CRUD with address, coordinates, metadata
|
||||
- CSV import/export (100,000+ records supported)
|
||||
- Multi-provider geocoding (6 providers)
|
||||
- Bulk geocoding with queue
|
||||
- NAR 2025 server-side import (Canadian electoral data)
|
||||
- Address standardization
|
||||
- Visit tracking integration
|
||||
|
||||
### Geographic Organization
|
||||
|
||||
- Polygon-based geographic cuts
|
||||
- GeoJSON import/export
|
||||
- Point-in-polygon queries
|
||||
- Cut-based location assignment
|
||||
- Spatial bounds calculation
|
||||
- Map visualization
|
||||
|
||||
### Volunteer Coordination
|
||||
|
||||
- Shift scheduling with cut assignment
|
||||
- Volunteer signup (authenticated + anonymous)
|
||||
- Email confirmations
|
||||
- Temp user creation for walk-ins
|
||||
- Shift capacity tracking
|
||||
|
||||
### Canvassing System
|
||||
|
||||
- GPS-enabled mobile interface
|
||||
- Walking route algorithm (nearest-neighbor)
|
||||
- Visit outcome recording (7 outcomes)
|
||||
- Session management (start/end/abandon)
|
||||
- Real-time progress tracking
|
||||
- Admin monitoring dashboard
|
||||
- Printable walk sheets with QR codes
|
||||
|
||||
### Map Display
|
||||
|
||||
- Public interactive Leaflet map
|
||||
- Color-coded markers by visit status
|
||||
- Polygon overlays for cuts
|
||||
- Geolocate button
|
||||
- Fullscreen mode
|
||||
- Legend and controls
|
||||
|
||||
## User Flow
|
||||
|
||||
### Admin Experience
|
||||
|
||||
1. **Import Locations** (`/app/map/locations`)
|
||||
- Upload CSV or NAR data
|
||||
- Geocode addresses
|
||||
- Review quality metrics
|
||||
- Bulk operations
|
||||
|
||||
2. **Create Cuts** (`/app/map/cuts`)
|
||||
- Draw polygons on map
|
||||
- Name and describe cut
|
||||
- Assign locations (automatic)
|
||||
- Export for printing
|
||||
|
||||
3. **Schedule Shifts** (`/app/map/shifts`)
|
||||
- Create shift with cut assignment
|
||||
- Set date/time/capacity
|
||||
- Email all volunteers
|
||||
- Monitor signups
|
||||
|
||||
4. **Monitor Canvassing** (`/app/canvass/dashboard`)
|
||||
- View active sessions
|
||||
- Track visit progress
|
||||
- Check leaderboard
|
||||
- Review activity feed
|
||||
|
||||
5. **Print Materials** (`/app/canvass/walk-sheet`)
|
||||
- Select cut
|
||||
- Generate walk sheet PDF
|
||||
- QR codes for quick access
|
||||
- Browser print
|
||||
|
||||
### Volunteer Experience
|
||||
|
||||
1. **View Assignments** (`/volunteer/assignments`)
|
||||
- See upcoming shifts
|
||||
- Cut information
|
||||
- Start canvass button
|
||||
|
||||
2. **Canvass** (`/volunteer/canvass/:cutId`)
|
||||
- Full-screen map with GPS
|
||||
- Follow walking route
|
||||
- Click markers to record visits
|
||||
- Select outcomes + notes
|
||||
- Track progress
|
||||
|
||||
3. **Review Activity** (`/volunteer/activity`)
|
||||
- Visit history
|
||||
- Outcome breakdown
|
||||
- Session statistics
|
||||
|
||||
### Public Experience
|
||||
|
||||
1. **View Map** (`/map`)
|
||||
- Browse locations
|
||||
- View cuts
|
||||
- See visit status (color-coded)
|
||||
- Geolocate self
|
||||
|
||||
2. **Sign Up for Shifts** (`/shifts`)
|
||||
- Browse available shifts
|
||||
- Signup with email
|
||||
- Receive confirmation
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend Components
|
||||
|
||||
**Modules:**
|
||||
- `api/src/modules/map/locations/` - Location CRUD + geocoding + NAR import
|
||||
- `api/src/modules/map/geocoding/` - Multi-provider geocoding service
|
||||
- `api/src/modules/map/cuts/` - Polygon CRUD + spatial queries
|
||||
- `api/src/modules/map/shifts/` - Shift CRUD + signups
|
||||
- `api/src/modules/map/canvass/` - Session + visit tracking
|
||||
- `api/src/modules/map/tracking/` - GPS tracking (future)
|
||||
- `api/src/modules/map/settings/` - Map settings singleton
|
||||
|
||||
**Services:**
|
||||
- `api/src/services/geocoding.service.ts` - Geocoding abstraction
|
||||
- `api/src/services/geocode-queue.service.ts` - Async geocoding
|
||||
|
||||
**Utilities:**
|
||||
- `api/src/utils/spatial.ts` - Point-in-polygon, haversine, bounds, centroid
|
||||
|
||||
**Database Models:**
|
||||
- `Location` - Address, coordinates, metadata, visit tracking
|
||||
- `Cut` - Name, GeoJSON polygon
|
||||
- `Shift` - Date/time, cut, capacity, signups
|
||||
- `CanvassSession` - Session tracking, start/end times
|
||||
- `CanvassVisit` - Visit outcomes, notes, GPS
|
||||
- `MapSettings` - Map center/zoom, walk sheet config
|
||||
|
||||
### Frontend Components
|
||||
|
||||
**Admin Pages:**
|
||||
- `admin/src/pages/LocationsPage.tsx` - Location management
|
||||
- `admin/src/pages/CutsPage.tsx` - Cut management
|
||||
- `admin/src/pages/ShiftsPage.tsx` - Shift management
|
||||
- `admin/src/pages/CanvassDashboardPage.tsx` - Canvass monitoring
|
||||
- `admin/src/pages/WalkSheetPage.tsx` - Printable materials
|
||||
- `admin/src/pages/DataQualityDashboardPage.tsx` - Quality metrics
|
||||
|
||||
**Public Pages:**
|
||||
- `admin/src/pages/public/MapPage.tsx` - Public map
|
||||
- `admin/src/pages/public/ShiftsPage.tsx` - Shift signup
|
||||
|
||||
**Volunteer Pages:**
|
||||
- `admin/src/pages/volunteer/VolunteerMapPage.tsx` - GPS canvass map
|
||||
- `admin/src/pages/volunteer/VolunteerShiftsPage.tsx` - Assignments
|
||||
- `admin/src/pages/volunteer/MyActivityPage.tsx` - Activity history
|
||||
|
||||
**Map Components:**
|
||||
- `admin/src/components/map/MapControls.tsx` - Control buttons
|
||||
- `admin/src/components/map/AddLocationMode.tsx` - Click-to-add
|
||||
- `admin/src/components/map/CutDrawingMode.tsx` - Polygon drawing
|
||||
- `admin/src/components/map/CutOverlays.tsx` - GeoJSON rendering
|
||||
|
||||
**Canvass Components:**
|
||||
- `admin/src/components/canvass/GPSTracker.tsx` - GPS tracking
|
||||
- `admin/src/components/canvass/WalkingRouteLine.tsx` - Route display
|
||||
- `admin/src/components/canvass/VisitRecordingForm.tsx` - Outcome form
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Geocoding Providers
|
||||
MAPBOX_ACCESS_TOKEN=pk_...
|
||||
GOOGLE_GEOCODE_API_KEY=...
|
||||
PELIAS_API_URL=http://pelias:4000
|
||||
|
||||
# NAR Import
|
||||
NAR_DATA_DIR=/data # NAR file directory (Docker volume)
|
||||
|
||||
# Map Settings
|
||||
MAP_DEFAULT_LAT=43.65 # Default map center
|
||||
MAP_DEFAULT_LNG=-79.38
|
||||
MAP_DEFAULT_ZOOM=12
|
||||
```
|
||||
|
||||
### Map Settings
|
||||
|
||||
Configurable via admin UI (`/app/map/settings`):
|
||||
- Default map center (lat/lng)
|
||||
- Default zoom level
|
||||
- Walk sheet header/footer
|
||||
- Display preferences
|
||||
|
||||
## Geocoding
|
||||
|
||||
### Supported Providers
|
||||
|
||||
1. **Nominatim** (OpenStreetMap) - Free, rate limited
|
||||
2. **ArcGIS** - Free tier available
|
||||
3. **Photon** - Free, self-hosted option
|
||||
4. **Mapbox** - API key required
|
||||
5. **Google Geocoding** - API key required
|
||||
6. **Pelias** - Self-hosted option
|
||||
|
||||
### Geocoding Strategy
|
||||
|
||||
1. Try provider 1 (Nominatim)
|
||||
2. If fails, try provider 2 (ArcGIS)
|
||||
3. Continue through providers
|
||||
4. Cache successful results
|
||||
5. Track quality metrics
|
||||
|
||||
### Bulk Geocoding
|
||||
|
||||
- BullMQ queue for async processing
|
||||
- Batch processing (100 locations/batch)
|
||||
- Provider rotation to avoid rate limits
|
||||
- Progress tracking
|
||||
- Error handling and retry
|
||||
|
||||
## NAR Import
|
||||
|
||||
Canadian electoral data (NAR 2025 format):
|
||||
|
||||
- **Address files** - Civic addresses with coordinates (EPSG:3347)
|
||||
- **Location files** - Building locations with lat/lng
|
||||
- **Join on LOC_GUID** - Combine address + coordinates
|
||||
- **Server-side streaming** - Memory-efficient for large files
|
||||
- **Filters** - Province, city, postal code, cut, residential-only
|
||||
|
||||
**Import Flow:**
|
||||
1. Scan NAR data directory
|
||||
2. List available provinces
|
||||
3. Stream Address + Location files
|
||||
4. Join on LOC_GUID
|
||||
5. Transform coordinates (proj4)
|
||||
6. Filter and insert locations
|
||||
|
||||
## Spatial Algorithms
|
||||
|
||||
### Point-in-Polygon
|
||||
|
||||
Ray-casting algorithm:
|
||||
- Count ray intersections with polygon edges
|
||||
- Odd count = inside, even count = outside
|
||||
- Supports holes in polygons
|
||||
- Used for cut assignment
|
||||
|
||||
### Walking Route
|
||||
|
||||
Nearest-neighbor algorithm:
|
||||
1. Start at closest location to shift start point
|
||||
2. For each location:
|
||||
- Find nearest unvisited location
|
||||
- Add to route
|
||||
- Mark as visited
|
||||
3. Return ordered list
|
||||
|
||||
### Haversine Distance
|
||||
|
||||
Great-circle distance between coordinates:
|
||||
- Returns distance in kilometers
|
||||
- Used for proximity sorting
|
||||
- Route optimization
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Locations
|
||||
|
||||
```
|
||||
GET /api/locations # List locations
|
||||
POST /api/locations # Create location
|
||||
GET /api/locations/:id # Get location
|
||||
PATCH /api/locations/:id # Update location
|
||||
DELETE /api/locations/:id # Delete location
|
||||
POST /api/locations/import # CSV import
|
||||
GET /api/locations/export # CSV export
|
||||
POST /api/locations/geocode # Bulk geocode
|
||||
```
|
||||
|
||||
### Cuts
|
||||
|
||||
```
|
||||
GET /api/cuts # List cuts
|
||||
POST /api/cuts # Create cut
|
||||
GET /api/cuts/:id # Get cut
|
||||
PATCH /api/cuts/:id # Update cut
|
||||
DELETE /api/cuts/:id # Delete cut
|
||||
POST /api/cuts/:id/assign-locations # Assign locations
|
||||
```
|
||||
|
||||
### Shifts
|
||||
|
||||
```
|
||||
GET /api/shifts # List shifts
|
||||
POST /api/shifts # Create shift
|
||||
GET /api/shifts/:id # Get shift
|
||||
PATCH /api/shifts/:id # Update shift
|
||||
DELETE /api/shifts/:id # Delete shift
|
||||
POST /api/shifts/:id/signup # Signup for shift
|
||||
```
|
||||
|
||||
### Canvassing
|
||||
|
||||
```
|
||||
POST /api/canvass/session/start # Start session
|
||||
POST /api/canvass/session/end # End session
|
||||
GET /api/canvass/session # Get active session
|
||||
POST /api/canvass/visit # Record visit
|
||||
GET /api/canvass/route/:cutId # Get walking route
|
||||
GET /api/canvass/dashboard # Dashboard stats
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Locations](locations.md)
|
||||
- [Geocoding](geocoding.md)
|
||||
- [NAR Import](nar-import.md)
|
||||
- [Cuts](cuts.md)
|
||||
- [Shifts](shifts.md)
|
||||
- [Canvassing](canvassing.md)
|
||||
- [Walk Sheets](walk-sheets.md)
|
||||
- [Data Quality](data-quality.md)
|
||||
- [Backend Locations Module](../../backend/modules/locations.md)
|
||||
- [Backend Canvass Module](../../backend/modules/canvass.md)
|
||||
- [Spatial Utilities](../../backend/utilities/index.md)
|
||||
- [Map Organizer Guide](../../user-guides/map-organizer-guide.md)
|
||||
- [Volunteer Guide](../../user-guides/volunteer-guide.md)
|
||||
1154
mkdocs/docs/v2/features/map/locations.md
Normal file
1154
mkdocs/docs/v2/features/map/locations.md
Normal file
File diff suppressed because it is too large
Load Diff
1827
mkdocs/docs/v2/features/map/nar-import.md
Normal file
1827
mkdocs/docs/v2/features/map/nar-import.md
Normal file
File diff suppressed because it is too large
Load Diff
946
mkdocs/docs/v2/features/map/shifts.md
Normal file
946
mkdocs/docs/v2/features/map/shifts.md
Normal file
@@ -0,0 +1,946 @@
|
||||
# Volunteer Shift Management
|
||||
|
||||
## Overview
|
||||
|
||||
The shifts system enables campaigns to organize volunteer activities with time-based scheduling, capacity management, and cut assignment. It supports public shift signup with automatic TEMP user creation for unauthenticated volunteers.
|
||||
|
||||
**Key Capabilities:**
|
||||
|
||||
- **Shift Scheduling**: Date, start/end times (HH:MM format), location
|
||||
- **Capacity Management**: Max volunteers, auto-status updates (OPEN → FULL)
|
||||
- **Cut Assignment**: Link shifts to geographic cuts for territory-based organizing
|
||||
- **Public Signup**: Unauthenticated users can signup (creates TEMP user)
|
||||
- **Email Confirmations**: Auto-send confirmation emails on signup
|
||||
- **Signup Tracking**: Source tracking (AUTHENTICATED, PUBLIC, ADMIN)
|
||||
- **Status Lifecycle**: OPEN, FULL, CANCELLED workflow
|
||||
- **Bulk Operations**: Email all volunteers, export signups CSV
|
||||
|
||||
**Use Cases:**
|
||||
|
||||
- Canvassing shift scheduling
|
||||
- Phone bank volunteer coordination
|
||||
- Event volunteer management
|
||||
- Door-knocking territory assignment
|
||||
- Get-out-the-vote (GOTV) shifts
|
||||
- Public volunteer recruitment
|
||||
- Volunteer confirmation emails
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Admin] -->|Creates Shift| B[ShiftsPage]
|
||||
B -->|POST /api/map/shifts| C[Shifts Service]
|
||||
C -->|Validate| D[Shift Model]
|
||||
D -->|Linked To| E[Cut Model]
|
||||
|
||||
F[Public User] -->|Browse Shifts| G[Public ShiftsPage]
|
||||
G -->|GET /api/public/map/shifts| C
|
||||
C -->|Filter upcoming=true| D
|
||||
|
||||
F -->|Signup| H[Signup Modal]
|
||||
H -->|POST /api/public/map/shifts/:id/signup| C
|
||||
C -->|Check Capacity| D
|
||||
C -->|Create TEMP User| I[User Service]
|
||||
C -->|Create Signup| J[ShiftSignup Model]
|
||||
C -->|Send Email| K[Email Service]
|
||||
|
||||
L[Volunteer] -->|View Assignments| M[VolunteerShiftsPage]
|
||||
M -->|GET /api/map/canvass/volunteer/assignments| N[Canvass Service]
|
||||
N -->|Filter by userId| J
|
||||
N -->|Include Cut| E
|
||||
|
||||
D -->|1:N| J
|
||||
D -->|N:1| E
|
||||
|
||||
style D fill:#e1f5ff
|
||||
style J fill:#e1f5ff
|
||||
style E fill:#e1f5ff
|
||||
style I fill:#e8f5e9
|
||||
```
|
||||
|
||||
**Flow Description:**
|
||||
|
||||
1. **Admin creates shift** → Validates date/time, assigns cut (optional), saves to database
|
||||
2. **Public user browses** → Query upcoming shifts (isPublic=true, date >=today), display cards
|
||||
3. **Public signup** → Check capacity, create TEMP user if unauthenticated, create signup record, send confirmation email
|
||||
4. **Volunteer views assignments** → Query signups for current user, include shift + cut details
|
||||
5. **Shift capacity check** → Auto-update status to FULL when currentVolunteers >= maxVolunteers
|
||||
|
||||
## Database Models
|
||||
|
||||
### Shift Model
|
||||
|
||||
See [Shift Model Documentation](../../database/models/map.md#shift-model) for full schema.
|
||||
|
||||
**Key Fields:**
|
||||
|
||||
- `title`: Shift name (e.g., "Saturday Canvassing - Downtown")
|
||||
- `description`: Free-text shift details
|
||||
- `date`: Shift date (Date type, not DateTime)
|
||||
- `startTime`: Start time in HH:MM format (24-hour)
|
||||
- `endTime`: End time in HH:MM format (24-hour)
|
||||
- `location`: Meeting point address/description
|
||||
- `maxVolunteers`: Maximum volunteer capacity
|
||||
- `currentVolunteers`: Current signup count (auto-updated)
|
||||
- `status`: OPEN | FULL | CANCELLED
|
||||
- `isPublic`: Show on public shifts page
|
||||
- `cutId`: Optional foreign key to Cut (territory assignment)
|
||||
- `createdBy`: User ID who created shift
|
||||
|
||||
**Status Enum:**
|
||||
|
||||
```typescript
|
||||
enum ShiftStatus {
|
||||
OPEN // Accepting signups
|
||||
FULL // At capacity
|
||||
CANCELLED // Cancelled by admin
|
||||
}
|
||||
```
|
||||
|
||||
### ShiftSignup Model
|
||||
|
||||
See [ShiftSignup Model Documentation](../../database/models/map.md#shiftsignup-model) for full schema.
|
||||
|
||||
**Key Fields:**
|
||||
|
||||
- `shiftId`: Foreign key to Shift
|
||||
- `userId`: Foreign key to User (optional for TEMP users)
|
||||
- `userEmail`: Email address (required, used for confirmations)
|
||||
- `userName`: Display name
|
||||
- `userPhone`: Phone number (optional)
|
||||
- `status`: CONFIRMED | CANCELLED | NO_SHOW
|
||||
- `signupDate`: When signup occurred
|
||||
- `signupSource`: AUTHENTICATED | PUBLIC | ADMIN
|
||||
- `notes`: Admin notes about signup
|
||||
|
||||
**Signup Source Enum:**
|
||||
|
||||
```typescript
|
||||
enum SignupSource {
|
||||
AUTHENTICATED // Logged-in user signup
|
||||
PUBLIC // Public signup (creates TEMP user)
|
||||
ADMIN // Admin created signup
|
||||
}
|
||||
```
|
||||
|
||||
**Signup Status Enum:**
|
||||
|
||||
```typescript
|
||||
enum SignupStatus {
|
||||
CONFIRMED // Signup active
|
||||
CANCELLED // Volunteer cancelled
|
||||
NO_SHOW // Marked as no-show by admin
|
||||
}
|
||||
```
|
||||
|
||||
**Related Models:**
|
||||
|
||||
- [Shift](../../database/models/map.md#shift-model) — Parent shift
|
||||
- [User](../../database/models/user.md) — Volunteer account (TEMP role for public signups)
|
||||
- [Cut](../../database/models/map.md#cut-model) — Geographic territory assignment
|
||||
- [CanvassSession](../../database/models/canvass.md#canvasssession-model) — Linked to shift for canvassing
|
||||
|
||||
## API Endpoints
|
||||
|
||||
See [Shifts Backend Module Documentation](../../backend/modules/map/shifts.md) for full API reference.
|
||||
|
||||
**Admin Endpoints:**
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| GET | `/api/map/shifts` | MAP_ADMIN | List shifts with pagination, search, filters |
|
||||
| GET | `/api/map/shifts/stats` | MAP_ADMIN | Get shift statistics (total, upcoming, by status) |
|
||||
| GET | `/api/map/shifts/:id` | MAP_ADMIN | Get shift details with signups |
|
||||
| POST | `/api/map/shifts` | MAP_ADMIN | Create new shift |
|
||||
| PATCH | `/api/map/shifts/:id` | MAP_ADMIN | Update shift |
|
||||
| DELETE | `/api/map/shifts/:id` | MAP_ADMIN | Delete shift (cascade signups) |
|
||||
| POST | `/api/map/shifts/:id/signups` | MAP_ADMIN | Manually add signup |
|
||||
| PATCH | `/api/map/shifts/:id/signups/:signupId` | MAP_ADMIN | Update signup (change status, notes) |
|
||||
| DELETE | `/api/map/shifts/:id/signups/:signupId` | MAP_ADMIN | Delete signup |
|
||||
| POST | `/api/map/shifts/:id/email-volunteers` | MAP_ADMIN | Send email to all shift volunteers |
|
||||
|
||||
**Public Endpoints:**
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| GET | `/api/public/map/shifts` | None | List upcoming public shifts (isPublic=true, date >=today) |
|
||||
| GET | `/api/public/map/shifts/:id` | None | Get public shift details |
|
||||
| POST | `/api/public/map/shifts/:id/signup` | None | Public signup (creates TEMP user if unauthenticated) |
|
||||
|
||||
**Volunteer Endpoints:**
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| GET | `/api/map/canvass/volunteer/assignments` | Any logged-in user | Get shifts user signed up for |
|
||||
| DELETE | `/api/map/shifts/:id/signups/cancel` | Any logged-in user | Cancel own signup |
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `EMAIL_TEST_MODE` | boolean | `false` | Send confirmation emails to MailHog (dev) |
|
||||
| `SMTP_HOST` | string | - | SMTP server for confirmation emails |
|
||||
| `SMTP_PORT` | number | `587` | SMTP port |
|
||||
| `SMTP_USER` | string | - | SMTP username |
|
||||
| `SMTP_PASSWORD` | string | - | SMTP password |
|
||||
|
||||
### Email Templates
|
||||
|
||||
**Shift Confirmation Email:**
|
||||
|
||||
Subject: `Shift Confirmation - {{shift.title}}`
|
||||
|
||||
Body:
|
||||
```
|
||||
Hi {{userName}},
|
||||
|
||||
You're confirmed for:
|
||||
{{shift.title}}
|
||||
|
||||
Date: {{shift.date}}
|
||||
Time: {{shift.startTime}} - {{shift.endTime}}
|
||||
Location: {{shift.location}}
|
||||
|
||||
{{#if shift.cut}}
|
||||
Territory: {{shift.cut.name}}
|
||||
{{/if}}
|
||||
|
||||
{{#if shift.description}}
|
||||
Details:
|
||||
{{shift.description}}
|
||||
{{/if}}
|
||||
|
||||
To cancel your signup, reply to this email.
|
||||
|
||||
Thank you!
|
||||
```
|
||||
|
||||
**Admin Email All Volunteers:**
|
||||
|
||||
Subject: Configurable by admin
|
||||
|
||||
Body: Configurable by admin (supports {{name}}, {{email}}, {{phone}} placeholders)
|
||||
|
||||
## Admin Workflow
|
||||
|
||||
### Creating a Shift
|
||||
|
||||
**Step 1: Navigate to Shifts Page**
|
||||
|
||||
Navigate to **Map → Shifts** in the admin sidebar.
|
||||
|
||||
![ShiftsPage Screenshot Placeholder]
|
||||
|
||||
**Step 2: Click "Add Shift"**
|
||||
|
||||
Click **+ Add Shift** button in the top-right corner.
|
||||
|
||||
**Step 3: Fill Shift Form**
|
||||
|
||||
Complete shift details:
|
||||
|
||||
- **Title**: "Saturday Canvassing - Ward 5"
|
||||
- **Description**: "Door-knocking downtown, meet at campaign office"
|
||||
- **Date**: Select date from calendar
|
||||
- **Start Time**: "09:00" (24-hour format)
|
||||
- **End Time**: "12:00"
|
||||
- **Location**: "123 Campaign Office, Main St"
|
||||
- **Max Volunteers**: 20
|
||||
- **Cut**: Select from dropdown (optional)
|
||||
- **Is Public**: Toggle to show on public shifts page
|
||||
|
||||
**Step 4: Save Shift**
|
||||
|
||||
Click **Create** to save shift. Status is automatically set to OPEN.
|
||||
|
||||
### Managing Signups
|
||||
|
||||
**Step 1: View Shift**
|
||||
|
||||
Click **Signups** button on a shift row to open signups drawer.
|
||||
|
||||
**Step 2: View Signup List**
|
||||
|
||||
Drawer displays:
|
||||
|
||||
- **Volunteer Name**: From signup or user account
|
||||
- **Email**: Contact email
|
||||
- **Phone**: Contact phone (if provided)
|
||||
- **Signup Date**: When volunteer signed up
|
||||
- **Source**: AUTHENTICATED | PUBLIC | ADMIN
|
||||
- **Status**: CONFIRMED | CANCELLED | NO_SHOW
|
||||
|
||||
**Step 3: Manually Add Signup (Admin)**
|
||||
|
||||
Click **Add Signup** button in drawer:
|
||||
|
||||
- **Email**: Required (validates format)
|
||||
- **Name**: Required
|
||||
- **Phone**: Optional
|
||||
- **Notes**: Admin notes
|
||||
|
||||
System will:
|
||||
|
||||
1. Check capacity (reject if FULL)
|
||||
2. Create TEMP user if email not in database
|
||||
3. Create signup with source=ADMIN
|
||||
4. Send confirmation email
|
||||
5. Update shift.currentVolunteers count
|
||||
|
||||
**Step 4: Mark No-Show**
|
||||
|
||||
Click **Mark No-Show** on signup row to update status. Useful for tracking volunteer reliability.
|
||||
|
||||
**Step 5: Delete Signup**
|
||||
|
||||
Click **Delete** to remove signup. Decrements shift.currentVolunteers count.
|
||||
|
||||
### Emailing All Volunteers
|
||||
|
||||
**Step 1: Click "Email All"**
|
||||
|
||||
On shift row, click **Email All** button.
|
||||
|
||||
**Step 2: Compose Email**
|
||||
|
||||
Modal opens with:
|
||||
|
||||
- **Subject**: Pre-filled with shift title
|
||||
- **Message**: Rich text editor with placeholders
|
||||
- **Placeholders**: {{name}}, {{email}}, {{phone}}, {{shift.title}}, {{shift.date}}, {{shift.startTime}}, {{shift.endTime}}
|
||||
|
||||
**Step 3: Preview**
|
||||
|
||||
Click **Preview** to see sample email with placeholders replaced.
|
||||
|
||||
**Step 4: Send**
|
||||
|
||||
Click **Send Email** to queue emails to all CONFIRMED volunteers. Uses BullMQ email queue for async processing.
|
||||
|
||||
### Updating Shift Status
|
||||
|
||||
**Step 1: Edit Shift**
|
||||
|
||||
Click **Edit** on shift row.
|
||||
|
||||
**Step 2: Change Status**
|
||||
|
||||
Update status dropdown:
|
||||
|
||||
- **OPEN**: Accepting signups
|
||||
- **FULL**: At capacity (auto-set when currentVolunteers >= maxVolunteers)
|
||||
- **CANCELLED**: Cancelled by admin
|
||||
|
||||
**Step 3: Save**
|
||||
|
||||
Click **Update**. If status changed to CANCELLED, optionally send cancellation email to all volunteers.
|
||||
|
||||
## Public Workflow
|
||||
|
||||
**Public users can browse and signup for shifts without authentication.**
|
||||
|
||||
**Step 1: Navigate to Public Shifts Page**
|
||||
|
||||
Visit `/shifts` (public route, no auth required).
|
||||
|
||||
**Step 2: Browse Shifts**
|
||||
|
||||
View upcoming shifts as cards:
|
||||
|
||||
- **Shift Title**: Large heading
|
||||
- **Date/Time**: Formatted date + time range
|
||||
- **Location**: Meeting point
|
||||
- **Volunteers**: "5 / 20 spots filled" progress bar
|
||||
- **Cut**: Territory name (if assigned)
|
||||
- **Status Badge**: OPEN (green), FULL (red), CANCELLED (gray)
|
||||
|
||||
**Step 3: Filter Shifts**
|
||||
|
||||
Use filters:
|
||||
|
||||
- **Date**: Show only shifts on specific date
|
||||
- **Status**: OPEN only (hide FULL/CANCELLED)
|
||||
|
||||
**Step 4: Click Signup**
|
||||
|
||||
Click **Signup** button on shift card. Modal opens.
|
||||
|
||||
**Step 5: Fill Signup Form**
|
||||
|
||||
Complete form:
|
||||
|
||||
- **Name**: Required
|
||||
- **Email**: Required (validates format)
|
||||
- **Phone**: Optional
|
||||
|
||||
**Step 6: Submit**
|
||||
|
||||
Click **Sign Up**. System will:
|
||||
|
||||
1. Check capacity (reject if FULL)
|
||||
2. Create TEMP user with email (if not exists)
|
||||
3. Create shift signup with source=PUBLIC
|
||||
4. Send confirmation email
|
||||
5. Update shift.currentVolunteers count
|
||||
6. Auto-update status to FULL if at capacity
|
||||
|
||||
**Step 7: Receive Confirmation**
|
||||
|
||||
Check email for confirmation with shift details.
|
||||
|
||||
## Volunteer Workflow
|
||||
|
||||
**Authenticated volunteers can view assigned shifts and cancel signups.**
|
||||
|
||||
**Step 1: Login**
|
||||
|
||||
Login at `/login` with volunteer account.
|
||||
|
||||
**Step 2: Navigate to Assignments**
|
||||
|
||||
Navigate to **Volunteer → My Assignments**.
|
||||
|
||||
**Step 3: View Assigned Shifts**
|
||||
|
||||
Table displays:
|
||||
|
||||
- **Shift Title**: Linked to shift details
|
||||
- **Date/Time**: Formatted
|
||||
- **Location**: Meeting point
|
||||
- **Cut**: Territory name (if assigned)
|
||||
- **Status**: Signup status
|
||||
|
||||
**Step 4: View Shift Details**
|
||||
|
||||
Click shift title to view:
|
||||
|
||||
- **Description**: Full shift details
|
||||
- **Volunteers**: List of other volunteers (names only, privacy protected)
|
||||
- **Map**: If cut assigned, show cut polygon on map
|
||||
|
||||
**Step 5: Cancel Signup**
|
||||
|
||||
Click **Cancel Signup** button. Confirmation modal appears.
|
||||
|
||||
**Step 6: Confirm Cancellation**
|
||||
|
||||
Click **Confirm**. System will:
|
||||
|
||||
1. Update signup status to CANCELLED
|
||||
2. Decrement shift.currentVolunteers count
|
||||
3. Update shift status to OPEN if was FULL
|
||||
4. Send cancellation confirmation email
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Shift Service Create (Backend)
|
||||
|
||||
```typescript
|
||||
// api/src/modules/map/shifts/shifts.service.ts
|
||||
async create(data: CreateShiftInput, userId: string) {
|
||||
const shift = await prisma.shift.create({
|
||||
data: {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
date: new Date(data.date),
|
||||
startTime: data.startTime,
|
||||
endTime: data.endTime,
|
||||
location: data.location,
|
||||
maxVolunteers: data.maxVolunteers,
|
||||
isPublic: data.isPublic,
|
||||
cutId: data.cutId,
|
||||
createdBy: userId,
|
||||
},
|
||||
});
|
||||
|
||||
return shift;
|
||||
}
|
||||
```
|
||||
|
||||
### Public Signup (Backend)
|
||||
|
||||
```typescript
|
||||
// api/src/modules/map/shifts/shifts.service.ts
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
async publicSignup(shiftId: string, data: PublicSignupInput) {
|
||||
const shift = await prisma.shift.findUnique({ where: { id: shiftId } });
|
||||
|
||||
if (!shift) {
|
||||
throw new AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND');
|
||||
}
|
||||
|
||||
// Check capacity
|
||||
if (shift.currentVolunteers >= shift.maxVolunteers) {
|
||||
throw new AppError(400, 'Shift is full', 'SHIFT_FULL');
|
||||
}
|
||||
|
||||
// Find or create TEMP user
|
||||
let user = await prisma.user.findUnique({ where: { email: data.email } });
|
||||
|
||||
if (!user) {
|
||||
const password = generateReadablePassword(); // e.g., "BlueEagle42"
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
phone: data.phone,
|
||||
password: hashedPassword,
|
||||
role: 'TEMP',
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('Created TEMP user for shift signup', {
|
||||
email: data.email,
|
||||
shiftId,
|
||||
});
|
||||
}
|
||||
|
||||
// Create signup
|
||||
const signup = await prisma.shiftSignup.create({
|
||||
data: {
|
||||
shiftId,
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
userName: user.name ?? data.name,
|
||||
userPhone: user.phone ?? data.phone,
|
||||
signupSource: SignupSource.PUBLIC,
|
||||
status: SignupStatus.CONFIRMED,
|
||||
},
|
||||
});
|
||||
|
||||
// Increment volunteer count
|
||||
await prisma.shift.update({
|
||||
where: { id: shiftId },
|
||||
data: {
|
||||
currentVolunteers: { increment: 1 },
|
||||
status: shift.currentVolunteers + 1 >= shift.maxVolunteers
|
||||
? ShiftStatus.FULL
|
||||
: shift.status,
|
||||
},
|
||||
});
|
||||
|
||||
// Send confirmation email
|
||||
await emailService.sendShiftConfirmation(user.email, shift, user.name ?? data.name);
|
||||
|
||||
recordShiftSignup('public');
|
||||
|
||||
return signup;
|
||||
}
|
||||
```
|
||||
|
||||
### Generate Readable Password (Backend)
|
||||
|
||||
```typescript
|
||||
// api/src/modules/map/shifts/shifts.service.ts
|
||||
const adjectives = ['Blue', 'Red', 'Green', 'Swift', 'Bright', 'Bold', 'Calm', 'Fair'];
|
||||
const nouns = ['Eagle', 'River', 'Mountain', 'Star', 'Forest', 'Lake', 'Wolf', 'Hawk'];
|
||||
|
||||
function generateReadablePassword(): string {
|
||||
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
|
||||
const noun = nouns[Math.floor(Math.random() * nouns.length)];
|
||||
const num = Math.floor(Math.random() * 90) + 10;
|
||||
return `${adj}${noun}${num}`;
|
||||
}
|
||||
|
||||
// Example output: "BoldWolf72", "SwiftStar45"
|
||||
```
|
||||
|
||||
### Shift Confirmation Email (Backend)
|
||||
|
||||
```typescript
|
||||
// api/src/services/email.service.ts
|
||||
async sendShiftConfirmation(
|
||||
to: string,
|
||||
shift: Shift,
|
||||
userName: string
|
||||
): Promise<void> {
|
||||
const subject = `Shift Confirmation - ${shift.title}`;
|
||||
|
||||
const body = `
|
||||
Hi ${userName},
|
||||
|
||||
You're confirmed for:
|
||||
${shift.title}
|
||||
|
||||
Date: ${dayjs(shift.date).format('MMMM D, YYYY')}
|
||||
Time: ${shift.startTime} - ${shift.endTime}
|
||||
Location: ${shift.location}
|
||||
|
||||
${shift.description ? `\nDetails:\n${shift.description}\n` : ''}
|
||||
|
||||
To cancel your signup, reply to this email.
|
||||
|
||||
Thank you!
|
||||
`;
|
||||
|
||||
await this.sendEmail({ to, subject, text: body });
|
||||
}
|
||||
```
|
||||
|
||||
### Public Shifts List (Frontend)
|
||||
|
||||
```typescript
|
||||
// admin/src/pages/public/ShiftsPage.tsx
|
||||
const fetchShifts = async () => {
|
||||
try {
|
||||
const { data } = await axios.get('/api/public/map/shifts', {
|
||||
params: {
|
||||
upcoming: true, // Only show future shifts
|
||||
},
|
||||
});
|
||||
|
||||
setShifts(data.shifts);
|
||||
} catch (error) {
|
||||
message.error('Failed to load shifts');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchShifts();
|
||||
}, []);
|
||||
```
|
||||
|
||||
### Signup Modal (Frontend)
|
||||
|
||||
```typescript
|
||||
// admin/src/pages/public/ShiftsPage.tsx
|
||||
const handleSignup = async (values: any) => {
|
||||
try {
|
||||
await axios.post(`/api/public/map/shifts/${selectedShift.id}/signup`, {
|
||||
name: values.name,
|
||||
email: values.email,
|
||||
phone: values.phone,
|
||||
});
|
||||
|
||||
message.success('Signup successful! Check your email for confirmation.');
|
||||
setSignupModalOpen(false);
|
||||
signupForm.resetFields();
|
||||
fetchShifts(); // Refresh to update volunteer count
|
||||
} catch (error: any) {
|
||||
if (error.response?.data?.code === 'SHIFT_FULL') {
|
||||
message.error('This shift is now full. Please choose another shift.');
|
||||
} else {
|
||||
message.error('Signup failed. Please try again.');
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Volunteer Assignments (Frontend)
|
||||
|
||||
```typescript
|
||||
// admin/src/pages/volunteer/VolunteerShiftsPage.tsx
|
||||
const fetchAssignments = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/map/canvass/volunteer/assignments');
|
||||
|
||||
setAssignments(data);
|
||||
} catch (error) {
|
||||
message.error('Failed to load assignments');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: Shift Status Not Auto-Updating to FULL
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Shift accepts signups beyond maxVolunteers
|
||||
- Status remains OPEN even when at capacity
|
||||
- currentVolunteers count incorrect
|
||||
|
||||
**Causes:**
|
||||
|
||||
- currentVolunteers not incremented on signup
|
||||
- Signup deletion not decrementing count
|
||||
- Race condition on concurrent signups
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Use database transaction** for capacity check + signup creation:
|
||||
|
||||
```typescript
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const shift = await tx.shift.findUnique({
|
||||
where: { id: shiftId },
|
||||
select: { currentVolunteers: true, maxVolunteers: true },
|
||||
});
|
||||
|
||||
if (shift.currentVolunteers >= shift.maxVolunteers) {
|
||||
throw new AppError(400, 'Shift is full', 'SHIFT_FULL');
|
||||
}
|
||||
|
||||
await tx.shiftSignup.create({ data: signupData });
|
||||
|
||||
await tx.shift.update({
|
||||
where: { id: shiftId },
|
||||
data: {
|
||||
currentVolunteers: { increment: 1 },
|
||||
status: shift.currentVolunteers + 1 >= shift.maxVolunteers
|
||||
? ShiftStatus.FULL
|
||||
: shift.status,
|
||||
},
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
2. **Verify count matches reality**:
|
||||
|
||||
```sql
|
||||
-- Check if currentVolunteers matches actual signup count
|
||||
SELECT s.id, s.title, s.currentVolunteers,
|
||||
COUNT(ss.id) as actual_signups
|
||||
FROM "Shift" s
|
||||
LEFT JOIN "ShiftSignup" ss ON s.id = ss."shiftId"
|
||||
AND ss.status = 'CONFIRMED'
|
||||
GROUP BY s.id
|
||||
HAVING s."currentVolunteers" != COUNT(ss.id);
|
||||
```
|
||||
|
||||
3. **Recalculate counts**:
|
||||
|
||||
```typescript
|
||||
// Admin utility to fix counts
|
||||
async function recalculateShiftCounts() {
|
||||
const shifts = await prisma.shift.findMany();
|
||||
|
||||
for (const shift of shifts) {
|
||||
const count = await prisma.shiftSignup.count({
|
||||
where: {
|
||||
shiftId: shift.id,
|
||||
status: SignupStatus.CONFIRMED,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.shift.update({
|
||||
where: { id: shift.id },
|
||||
data: {
|
||||
currentVolunteers: count,
|
||||
status: count >= shift.maxVolunteers ? ShiftStatus.FULL : ShiftStatus.OPEN,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Issue: Confirmation Emails Not Sending
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Users signup successfully but no email received
|
||||
- MailHog shows no emails in dev
|
||||
- SMTP errors in API logs
|
||||
|
||||
**Causes:**
|
||||
|
||||
- EMAIL_TEST_MODE not set in dev
|
||||
- SMTP credentials invalid
|
||||
- Email service not configured
|
||||
- Email in spam folder
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Check email service config**:
|
||||
|
||||
```bash
|
||||
# Verify SMTP settings in .env
|
||||
grep "SMTP_\|EMAIL_TEST_MODE" .env
|
||||
|
||||
# In development, use MailHog
|
||||
EMAIL_TEST_MODE=true
|
||||
|
||||
# In production, configure SMTP
|
||||
EMAIL_TEST_MODE=false
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASSWORD=your-app-password
|
||||
```
|
||||
|
||||
2. **Test email service**:
|
||||
|
||||
```bash
|
||||
# Send test email via API
|
||||
curl -X POST http://localhost:4000/api/test-email \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"to":"test@example.com","subject":"Test","text":"Test email"}'
|
||||
```
|
||||
|
||||
3. **Check MailHog in dev**:
|
||||
|
||||
```bash
|
||||
# Access MailHog UI
|
||||
open http://localhost:8025
|
||||
|
||||
# View email queue in BullMQ
|
||||
docker compose exec redis redis-cli KEYS "bull:email-queue:*"
|
||||
```
|
||||
|
||||
4. **Check spam folder** (production):
|
||||
|
||||
Add SPF/DKIM/DMARC records to domain to improve deliverability.
|
||||
|
||||
### Issue: TEMP User Password Security
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- TEMP users can't login with generated password
|
||||
- Password doesn't meet complexity requirements
|
||||
- Account locked after signup
|
||||
|
||||
**Causes:**
|
||||
|
||||
- Generated password doesn't meet 12-char minimum
|
||||
- Password missing uppercase/lowercase/digit
|
||||
- Password not sent to user (they can't login)
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Ensure generated password meets policy**:
|
||||
|
||||
```typescript
|
||||
function generateReadablePassword(): string {
|
||||
// Must meet: 12+ chars, uppercase, lowercase, digit
|
||||
const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; // Uppercase
|
||||
const noun = nouns[Math.floor(Math.random() * nouns.length)]; // Uppercase
|
||||
const num = Math.floor(Math.random() * 90) + 10; // 2 digits
|
||||
const lower = 'abc'; // Lowercase
|
||||
|
||||
return `${adj}${noun}${num}${lower}`; // E.g., "BoldWolf72abc" (14 chars)
|
||||
}
|
||||
```
|
||||
|
||||
2. **Send password to user** (security risk, consider alternative):
|
||||
|
||||
Include password in confirmation email (only for TEMP users, one-time):
|
||||
|
||||
```
|
||||
Your temporary account has been created.
|
||||
|
||||
Email: {{email}}
|
||||
Password: {{password}}
|
||||
|
||||
Please change your password after logging in.
|
||||
```
|
||||
|
||||
**Better Alternative**: Use passwordless login link:
|
||||
|
||||
```
|
||||
Click here to confirm your shift and access your account:
|
||||
https://app.cmlite.org/confirm-shift/{{signupToken}}
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Shift Query Optimization
|
||||
|
||||
**Index Upcoming Shifts:**
|
||||
|
||||
Create composite index for common query:
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_shifts_upcoming ON "Shift" (date, "isPublic", status)
|
||||
WHERE date >= CURRENT_DATE;
|
||||
```
|
||||
|
||||
**Efficient Public Query:**
|
||||
|
||||
```typescript
|
||||
// Only query future public shifts
|
||||
const shifts = await prisma.shift.findMany({
|
||||
where: {
|
||||
isPublic: true,
|
||||
date: { gte: new Date() },
|
||||
status: { not: ShiftStatus.CANCELLED },
|
||||
},
|
||||
orderBy: { date: 'asc' },
|
||||
include: {
|
||||
cut: { select: { id: true, name: true } },
|
||||
_count: {
|
||||
select: { signups: { where: { status: SignupStatus.CONFIRMED } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Email Queue Performance
|
||||
|
||||
**Batch Email Sending:**
|
||||
|
||||
Use BullMQ queue to avoid blocking API requests:
|
||||
|
||||
```typescript
|
||||
// Add email jobs to queue
|
||||
for (const volunteer of volunteers) {
|
||||
await emailQueue.add('send-email', {
|
||||
to: volunteer.email,
|
||||
subject: 'Shift Update',
|
||||
text: message,
|
||||
});
|
||||
}
|
||||
|
||||
// Worker processes jobs asynchronously
|
||||
emailQueue.process('send-email', async (job) => {
|
||||
await emailService.sendEmail(job.data);
|
||||
});
|
||||
```
|
||||
|
||||
### Concurrent Signup Handling
|
||||
|
||||
**Prevent Race Conditions:**
|
||||
|
||||
Use database transactions with `SELECT FOR UPDATE`:
|
||||
|
||||
```typescript
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const shift = await tx.shift.findUnique({
|
||||
where: { id: shiftId },
|
||||
// Lock row to prevent concurrent updates
|
||||
});
|
||||
|
||||
if (shift.currentVolunteers >= shift.maxVolunteers) {
|
||||
throw new AppError(400, 'Shift is full', 'SHIFT_FULL');
|
||||
}
|
||||
|
||||
// Create signup and update count atomically
|
||||
await tx.shiftSignup.create({ data: signupData });
|
||||
await tx.shift.update({
|
||||
where: { id: shiftId },
|
||||
data: { currentVolunteers: { increment: 1 } },
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
**Backend Modules:**
|
||||
|
||||
- [Shifts Backend Module](../../backend/modules/map/shifts.md) — API implementation
|
||||
- [Email Service](../../backend/modules/services/email.md) — Confirmation emails
|
||||
|
||||
**Frontend Pages:**
|
||||
|
||||
- [ShiftsPage](../../frontend/pages/admin/shifts-page.md) — Admin CRUD interface
|
||||
- [Public ShiftsPage](../../frontend/pages/public/shifts-page.md) — Public signup
|
||||
- [VolunteerShiftsPage](../../frontend/pages/volunteer/shifts-page.md) — Volunteer assignments
|
||||
|
||||
**Database:**
|
||||
|
||||
- [Shift Model](../../database/models/map.md#shift-model) — Shift schema
|
||||
- [ShiftSignup Model](../../database/models/map.md#shiftsignup-model) — Signup records
|
||||
- [User Model](../../database/models/user.md) — TEMP user accounts
|
||||
|
||||
**Features:**
|
||||
|
||||
- [Cuts](./cuts.md) — Territory assignment for shifts
|
||||
- [Canvassing](./canvassing.md) — Shift-based canvassing sessions
|
||||
- [Users](../auth/users.md) — TEMP user management
|
||||
409
mkdocs/docs/v2/features/map/tracking.md
Normal file
409
mkdocs/docs/v2/features/map/tracking.md
Normal file
@@ -0,0 +1,409 @@
|
||||
# GPS Tracking System
|
||||
|
||||
## Overview
|
||||
|
||||
The GPS tracking system provides real-time volunteer location monitoring with breadcrumb trail recording, distance calculation, and route visualization. It integrates with canvassing sessions for field organizing oversight and volunteer safety.
|
||||
|
||||
**Key Capabilities:**
|
||||
|
||||
- **Live Tracking**: Real-time volunteer GPS positions
|
||||
- **Breadcrumb Trails**: Auto-record GPS points every 10 seconds
|
||||
- **Distance Calculation**: Haversine formula for accurate walking distance
|
||||
- **Event Markers**: Mark key events (session start, visits, session end)
|
||||
- **Route Visualization**: Leaflet polyline with color-coded event markers
|
||||
- **1:1 Canvass Link**: Each TrackingSession linked to one CanvassSession
|
||||
- **Admin Oversight**: View live volunteer positions on map
|
||||
- **Privacy Controls**: Tracking only during active canvass sessions
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Volunteer GPS] -->|watchPosition| B[GPSTracker Component]
|
||||
B -->|Buffer Points| C[Local Storage]
|
||||
C -->|Submit Every 10s| D[POST /api/map/tracking/sessions/:id/points]
|
||||
D -->|Batch Insert| E[Tracking Service]
|
||||
E -->|Save Points| F[(TrackPoint Model)]
|
||||
E -->|Calculate Distance| G[Haversine Formula]
|
||||
G -->|Update Session| H[(TrackingSession Model)]
|
||||
|
||||
I[Canvass Session] -->|Start| J[Canvass Service]
|
||||
J -->|Create 1:1| E
|
||||
E -->|Create| H
|
||||
|
||||
K[Admin] -->|View Live Map| L[CanvassDashboardPage]
|
||||
L -->|GET /api/map/tracking/admin/live| E
|
||||
E -->|Query Active| H
|
||||
E -->|Return Positions| L
|
||||
|
||||
M[Volunteer] -->|View Route History| N[MyRoutesPage]
|
||||
N -->|GET /api/map/tracking/sessions/:id/route| E
|
||||
E -->|Query Points| F
|
||||
E -->|Generate Polyline| N
|
||||
|
||||
H -->|1:1| I
|
||||
H -->|1:N| F
|
||||
|
||||
style H fill:#e1f5ff
|
||||
style F fill:#e1f5ff
|
||||
```
|
||||
|
||||
**Flow Description:**
|
||||
|
||||
1. **Canvass session starts** → Create TrackingSession linked 1:1
|
||||
2. **GPS auto-tracking** → watchPosition submits points every 10s
|
||||
3. **Distance calculation** → Haversine formula calculates incremental distance
|
||||
4. **Event markers** → Mark visits, session start/end with eventType
|
||||
5. **Admin oversight** → View live volunteer positions on dashboard
|
||||
6. **Route history** → Generate polyline from saved TrackPoints
|
||||
|
||||
## Database Models
|
||||
|
||||
### TrackingSession Model
|
||||
|
||||
See [TrackingSession Model Documentation](../../database/models/canvass.md#trackingsession-model).
|
||||
|
||||
**Key Fields:**
|
||||
|
||||
- `userId`: Foreign key to volunteer User
|
||||
- `canvassSessionId`: 1:1 foreign key to CanvassSession
|
||||
- `startedAt`: Tracking start timestamp
|
||||
- `endedAt`: Tracking end timestamp (null while active)
|
||||
- `isActive`: Boolean - tracking currently running
|
||||
- `totalPoints`: Count of TrackPoint records
|
||||
- `totalDistanceM`: Total distance walked in meters
|
||||
- `lastLatitude` / `lastLongitude`: Most recent GPS position
|
||||
- `lastRecordedAt`: Timestamp of last GPS point
|
||||
|
||||
### TrackPoint Model
|
||||
|
||||
See [TrackPoint Model Documentation](../../database/models/canvass.md#trackpoint-model).
|
||||
|
||||
**Key Fields:**
|
||||
|
||||
- `trackingSessionId`: Foreign key to TrackingSession
|
||||
- `latitude` / `longitude`: GPS coordinates (Decimal type)
|
||||
- `accuracy`: GPS accuracy in meters (lower = better)
|
||||
- `recordedAt`: When point was recorded (client timestamp)
|
||||
- `eventType`: Optional event marker (LOCATION_ADDED, VISIT_RECORDED, SESSION_STARTED, SESSION_ENDED)
|
||||
|
||||
**Event Type Enum:**
|
||||
|
||||
```typescript
|
||||
enum TrackPointEventType {
|
||||
LOCATION_ADDED // Regular GPS breadcrumb
|
||||
VISIT_RECORDED // Canvass visit recorded
|
||||
SESSION_STARTED // Canvass session started
|
||||
SESSION_ENDED // Canvass session ended
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
See [Tracking Backend Module Documentation](../../backend/modules/map/tracking.md).
|
||||
|
||||
**Volunteer Endpoints:**
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| POST | `/api/map/tracking/sessions` | Any logged-in user | Start tracking session |
|
||||
| PATCH | `/api/map/tracking/sessions/:id/end` | Any logged-in user | End tracking session |
|
||||
| POST | `/api/map/tracking/sessions/:id/points` | Any logged-in user | Submit batch of GPS points |
|
||||
| GET | `/api/map/tracking/sessions/:id` | Any logged-in user | Get tracking session details |
|
||||
| GET | `/api/map/tracking/sessions/:id/route` | Any logged-in user | Get route polyline (all points) |
|
||||
|
||||
**Admin Endpoints:**
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| GET | `/api/map/tracking/admin/live` | MAP_ADMIN | Get live volunteer positions |
|
||||
| GET | `/api/map/tracking/admin/sessions/:id` | MAP_ADMIN | Get volunteer tracking session |
|
||||
| GET | `/api/map/tracking/admin/sessions/:id/route` | MAP_ADMIN | Get volunteer route |
|
||||
|
||||
## Configuration
|
||||
|
||||
### GPS Tracking Settings
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `SUBMIT_INTERVAL_MS` | `10000` | Submit GPS points every 10 seconds |
|
||||
| `MAX_DISTANCE_JUMP_M` | `1000` | Ignore GPS glitches >1km distance |
|
||||
| `HIGH_ACCURACY` | `true` | Use GPS + WiFi + cellular (vs WiFi only) |
|
||||
| `MAX_AGE_MS` | `0` | Don't use cached GPS position |
|
||||
| `TIMEOUT_MS` | `10000` | GPS position timeout (10s) |
|
||||
|
||||
### Privacy & Security
|
||||
|
||||
- **Opt-In Only**: Tracking only enabled when volunteer starts canvass session
|
||||
- **Session-Based**: Tracking ends when session ends (not continuous)
|
||||
- **Admin-Only**: Only MAP_ADMIN can view live positions
|
||||
- **Data Retention**: TrackPoints retained for analytics (consider GDPR compliance for EU campaigns)
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Start Tracking Session (Backend)
|
||||
|
||||
```typescript
|
||||
// api/src/modules/map/tracking/tracking.service.ts
|
||||
async startSession(userId: string, data: StartTrackingInput) {
|
||||
const { canvassSessionId, latitude, longitude } = data;
|
||||
|
||||
// Check for existing active session
|
||||
const existing = await prisma.trackingSession.findFirst({
|
||||
where: { userId, isActive: true },
|
||||
});
|
||||
|
||||
if (existing) return existing; // Reuse existing session
|
||||
|
||||
return prisma.trackingSession.create({
|
||||
data: {
|
||||
userId,
|
||||
canvassSessionId: canvassSessionId ?? null,
|
||||
lastLatitude: latitude != null ? new Prisma.Decimal(latitude) : null,
|
||||
lastLongitude: longitude != null ? new Prisma.Decimal(longitude) : null,
|
||||
lastRecordedAt: latitude != null ? new Date() : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Submit GPS Points (Backend)
|
||||
|
||||
```typescript
|
||||
// api/src/modules/map/tracking/tracking.service.ts
|
||||
const MAX_DISTANCE_JUMP_M = 1000;
|
||||
|
||||
async submitPoints(sessionId: string, userId: string, data: SubmitPointsInput) {
|
||||
const session = await prisma.trackingSession.findFirst({
|
||||
where: { id: sessionId, userId, isActive: true },
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
throw new AppError(404, 'Active tracking session not found', 'SESSION_NOT_FOUND');
|
||||
}
|
||||
|
||||
const { points } = data;
|
||||
|
||||
// Batch insert all points
|
||||
await prisma.trackPoint.createMany({
|
||||
data: points.map((p) => ({
|
||||
trackingSessionId: sessionId,
|
||||
latitude: new Prisma.Decimal(p.latitude),
|
||||
longitude: new Prisma.Decimal(p.longitude),
|
||||
accuracy: p.accuracy ?? null,
|
||||
recordedAt: new Date(p.recordedAt),
|
||||
eventType: p.eventType ?? null,
|
||||
})),
|
||||
});
|
||||
|
||||
// Calculate incremental distance
|
||||
let addedDistance = 0;
|
||||
let prevLat = session.lastLatitude ? Number(session.lastLatitude) : null;
|
||||
let prevLng = session.lastLongitude ? Number(session.lastLongitude) : null;
|
||||
|
||||
const sorted = [...points].sort(
|
||||
(a, b) => new Date(a.recordedAt).getTime() - new Date(b.recordedAt).getTime()
|
||||
);
|
||||
|
||||
for (const p of sorted) {
|
||||
if (prevLat != null && prevLng != null) {
|
||||
const d = haversineDistance(prevLat, prevLng, p.latitude, p.longitude);
|
||||
if (d <= MAX_DISTANCE_JUMP_M) {
|
||||
addedDistance += d;
|
||||
}
|
||||
}
|
||||
prevLat = p.latitude;
|
||||
prevLng = p.longitude;
|
||||
}
|
||||
|
||||
const lastPoint = sorted[sorted.length - 1]!;
|
||||
|
||||
// Update session summary
|
||||
await prisma.trackingSession.update({
|
||||
where: { id: sessionId },
|
||||
data: {
|
||||
totalPoints: { increment: points.length },
|
||||
totalDistanceM: { increment: addedDistance },
|
||||
lastLatitude: new Prisma.Decimal(lastPoint.latitude),
|
||||
lastLongitude: new Prisma.Decimal(lastPoint.longitude),
|
||||
lastRecordedAt: new Date(lastPoint.recordedAt),
|
||||
},
|
||||
});
|
||||
|
||||
return { accepted: points.length, distance: addedDistance };
|
||||
}
|
||||
```
|
||||
|
||||
### GPS Auto-Tracking (Frontend)
|
||||
|
||||
```typescript
|
||||
// admin/src/components/canvass/GPSTracker.tsx
|
||||
useEffect(() => {
|
||||
if (!trackingSessionId || !enabled) return;
|
||||
|
||||
const pointsBuffer: TrackPoint[] = [];
|
||||
|
||||
const watchId = navigator.geolocation.watchPosition(
|
||||
(position) => {
|
||||
const point = {
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
accuracy: position.coords.accuracy,
|
||||
recordedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
pointsBuffer.push(point);
|
||||
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.splice(0), // Drain buffer
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to submit GPS points:', error);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
return () => {
|
||||
navigator.geolocation.clearWatch(watchId);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [trackingSessionId, enabled]);
|
||||
```
|
||||
|
||||
### Route Visualization (Frontend)
|
||||
|
||||
```typescript
|
||||
// admin/src/pages/volunteer/MyRoutesPage.tsx
|
||||
const fetchRoute = async (sessionId: string) => {
|
||||
const { data } = await api.get(`/map/tracking/sessions/${sessionId}/route`);
|
||||
|
||||
// Convert TrackPoints to polyline coordinates
|
||||
const polyline = data.points.map((p: TrackPoint) => [p.latitude, p.longitude]);
|
||||
|
||||
// Extract event markers
|
||||
const events = data.points
|
||||
.filter((p: TrackPoint) => p.eventType)
|
||||
.map((p: TrackPoint) => ({
|
||||
position: [p.latitude, p.longitude],
|
||||
eventType: p.eventType,
|
||||
recordedAt: p.recordedAt,
|
||||
}));
|
||||
|
||||
setRoute({ polyline, events, distance: data.totalDistanceM });
|
||||
};
|
||||
|
||||
// Render route
|
||||
<Polyline positions={route.polyline} pathOptions={{ color: '#3498db', weight: 3 }} />
|
||||
{route.events.map((event, i) => (
|
||||
<Marker
|
||||
key={i}
|
||||
position={event.position}
|
||||
icon={getEventIcon(event.eventType)}
|
||||
>
|
||||
<Popup>{event.eventType} - {dayjs(event.recordedAt).format('HH:mm')}</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: GPS Tracking Draining Battery
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Reduce accuracy: `enableHighAccuracy: false`
|
||||
2. Increase submit interval: `SUBMIT_INTERVAL_MS = 30000` (30s)
|
||||
3. Add pause/resume tracking buttons
|
||||
|
||||
### Issue: Distance Calculation Incorrect
|
||||
|
||||
**Symptoms:** Total distance much higher than expected
|
||||
|
||||
**Causes:** GPS glitches causing large jumps
|
||||
|
||||
**Solutions:**
|
||||
|
||||
Increase `MAX_DISTANCE_JUMP_M` threshold to ignore outliers:
|
||||
|
||||
```typescript
|
||||
const MAX_DISTANCE_JUMP_M = 2000; // Was 1000, increase to 2000
|
||||
```
|
||||
|
||||
### Issue: Route Polyline Jagged
|
||||
|
||||
**Symptoms:** Route looks zigzag instead of smooth
|
||||
|
||||
**Causes:** GPS accuracy poor (±20m)
|
||||
|
||||
**Solutions:**
|
||||
|
||||
Apply smoothing algorithm to polyline:
|
||||
|
||||
```typescript
|
||||
import { simplify } from '@turf/turf';
|
||||
|
||||
const smoothed = simplify(polyline, { tolerance: 0.0001, highQuality: true });
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Batch Point Insertion
|
||||
|
||||
**Efficient Bulk Insert:**
|
||||
|
||||
```typescript
|
||||
// Insert all points in single transaction
|
||||
await prisma.trackPoint.createMany({
|
||||
data: points.map((p) => ({ ... })),
|
||||
});
|
||||
|
||||
// Avoid N+1: single UPDATE instead of N UPDATEs
|
||||
await prisma.trackingSession.update({
|
||||
where: { id: sessionId },
|
||||
data: {
|
||||
totalPoints: { increment: points.length },
|
||||
totalDistanceM: { increment: totalDistance },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Query Optimization
|
||||
|
||||
**Index for Route Queries:**
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_track_points_session_time ON "TrackPoint" ("trackingSessionId", "recordedAt");
|
||||
```
|
||||
|
||||
**Efficient Route Query:**
|
||||
|
||||
```typescript
|
||||
const points = await prisma.trackPoint.findMany({
|
||||
where: { trackingSessionId: sessionId },
|
||||
orderBy: { recordedAt: 'asc' },
|
||||
select: { latitude: true, longitude: true, recordedAt: true, eventType: true },
|
||||
});
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Canvassing](./canvassing.md) — Canvass session integration
|
||||
- [Tracking Backend Module](../../backend/modules/map/tracking.md)
|
||||
- [MyRoutesPage](../../frontend/pages/volunteer/my-routes-page.md)
|
||||
- [TrackingSession Model](../../database/models/canvass.md#trackingsession-model)
|
||||
2235
mkdocs/docs/v2/features/map/walk-sheets.md
Normal file
2235
mkdocs/docs/v2/features/map/walk-sheets.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user