287 lines
7.2 KiB
Markdown
287 lines
7.2 KiB
Markdown
# Map Models
|
|
|
|
## Overview
|
|
|
|
The Map module provides building-level and unit-level location management with multi-provider geocoding, volunteer shift scheduling, GeoJSON polygon cuts for map filtering, and comprehensive audit trails.
|
|
|
|
**Models (7):**
|
|
- Location — Building-level with lat/lng, NAR integration
|
|
- Address — Unit-level with support levels
|
|
- LocationHistory — Audit trail (7 action types)
|
|
- Shift — Volunteer shifts with cut relation
|
|
- ShiftSignup — Signup tracking
|
|
- Cut — GeoJSON polygon overlays
|
|
- MapSettings — Singleton configuration
|
|
|
|
**Key Features:**
|
|
- Building vs unit architecture (1 Location → many Addresses)
|
|
- Multi-provider geocoding (6 providers: Google, Mapbox, Nominatim, Photon, LocationIQ, ArcGIS)
|
|
- NAR 2025 Canadian electoral data import
|
|
- Spatial indexing (latitude/longitude composite index)
|
|
- GeoJSON polygon storage for cuts
|
|
- Walk sheet generation with QR codes
|
|
- CSV import/export
|
|
|
|
See [Schema Reference](../schema.md#map--locations) for complete field listings.
|
|
|
|
---
|
|
|
|
## Building vs Unit Architecture
|
|
|
|
**Location** = Building-level data:
|
|
- Single lat/lng coordinate
|
|
- Street address (no unit number)
|
|
- Building type (SINGLE_FAMILY, MULTI_UNIT, MIXED_USE, COMMERCIAL)
|
|
- Total units count
|
|
- Building notes (access codes, manager contact)
|
|
|
|
**Address** = Unit-level data:
|
|
- Unit number (apartment #, suite #)
|
|
- Occupant name/email/phone
|
|
- Support level (1-4)
|
|
- Sign request flag
|
|
- Canvassing notes
|
|
|
|
**Relationship:** `Location ||--o{ Address` (one-to-many)
|
|
|
|
**Example:**
|
|
```typescript
|
|
// 1 Location: 123 Main St (4-unit apartment building)
|
|
const location = {
|
|
address: '123 Main St',
|
|
latitude: 53.5461,
|
|
longitude: -113.4938,
|
|
buildingType: 'MULTI_UNIT',
|
|
totalUnits: 4,
|
|
};
|
|
|
|
// 4 Addresses: Units 101-104
|
|
const addresses = [
|
|
{ locationId, unitNumber: '101', firstName: 'Alice', supportLevel: '4' },
|
|
{ locationId, unitNumber: '102', firstName: 'Bob', supportLevel: '3' },
|
|
{ locationId, unitNumber: '103', firstName: 'Carol', supportLevel: '2' },
|
|
{ locationId, unitNumber: '104', firstName: 'Dave', supportLevel: '1' },
|
|
];
|
|
```
|
|
|
|
---
|
|
|
|
## Geocoding Providers
|
|
|
|
```prisma
|
|
enum GeocodeProvider {
|
|
GOOGLE // Google Maps Geocoding API
|
|
MAPBOX // Mapbox Geocoding API
|
|
NOMINATIM // OpenStreetMap Nominatim
|
|
PHOTON // Photon (OSM-based)
|
|
LOCATIONIQ // LocationIQ (OSM-based)
|
|
ARCGIS // ArcGIS Geocoding Service
|
|
UNKNOWN // Manually entered or unknown source
|
|
}
|
|
```
|
|
|
|
**Provider Priority:**
|
|
1. Google (highest accuracy, paid)
|
|
2. Mapbox (high accuracy, paid)
|
|
3. ArcGIS (high accuracy, free tier)
|
|
4. Nominatim (medium accuracy, free)
|
|
5. Photon (medium accuracy, free)
|
|
6. LocationIQ (medium accuracy, free tier)
|
|
|
|
**Confidence Score:** 0-100 (stored in `geocodeConfidence` field)
|
|
|
|
---
|
|
|
|
## NAR 2025 Import
|
|
|
|
**NAR** = National Address Register (Canadian electoral data)
|
|
|
|
**Import Features:**
|
|
- Streams large CSV files (no memory limit)
|
|
- Joins Location + Address files on LOC_GUID
|
|
- Converts BG_X/BG_Y (EPSG:3347 Lambert projection) → lat/lng
|
|
- Province selector (codes 10-62)
|
|
- City/postal/cut filters
|
|
- Residential-only toggle (buildingUse = 1)
|
|
|
|
**New Location Fields:**
|
|
- `postalCode` — Canadian postal code
|
|
- `province` — Province code (e.g., "AB")
|
|
- `federalDistrict` — Federal electoral district
|
|
- `buildingUse` — NAR BU_USE (1=Residential, 2=Partial, 3=Non-Residential, 4=Unknown)
|
|
- `locGuid` — NAR LOC_GUID (unique)
|
|
|
|
**New Address Fields:**
|
|
- `addrGuid` — NAR ADDR_GUID (unique)
|
|
|
|
---
|
|
|
|
## LocationHistory Actions
|
|
|
|
```prisma
|
|
enum LocationHistoryAction {
|
|
CREATED // Location created
|
|
UPDATED // Location updated
|
|
GEOCODED // Single location geocoded
|
|
BULK_GEOCODED // Batch geocode operation
|
|
MOVED_ON_MAP // Dragged on admin map
|
|
IMPORTED_CSV // CSV import
|
|
IMPORTED_NAR // NAR import
|
|
}
|
|
```
|
|
|
|
**Audit Fields:**
|
|
- `field` — Which field changed (e.g., "latitude")
|
|
- `oldValue` — Previous value
|
|
- `newValue` — New value
|
|
- `metadata` — JSON with provider, confidence, etc.
|
|
|
|
---
|
|
|
|
## Cut GeoJSON Storage
|
|
|
|
**Cut** stores GeoJSON polygon coordinates:
|
|
|
|
```json
|
|
{
|
|
"type": "Polygon",
|
|
"coordinates": [
|
|
[
|
|
[-113.5, 53.5],
|
|
[-113.4, 53.5],
|
|
[-113.4, 53.6],
|
|
[-113.5, 53.6],
|
|
[-113.5, 53.5]
|
|
]
|
|
]
|
|
}
|
|
```
|
|
|
|
**Bounds:** Calculated bounding box for quick filtering:
|
|
```json
|
|
{
|
|
"north": 53.6,
|
|
"south": 53.5,
|
|
"east": -113.4,
|
|
"west": -113.5
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Shift Status Workflow
|
|
|
|
```mermaid
|
|
stateDiagram-v2
|
|
[*] --> OPEN : Create shift
|
|
OPEN --> FULL : currentVolunteers >= maxVolunteers
|
|
OPEN --> CANCELLED : Admin cancels
|
|
FULL --> OPEN : Volunteer cancels (currentVolunteers < maxVolunteers)
|
|
FULL --> CANCELLED : Admin cancels
|
|
CANCELLED --> [*]
|
|
```
|
|
|
|
---
|
|
|
|
## Common Queries
|
|
|
|
### Create Location with Geocoding
|
|
```typescript
|
|
const location = await prisma.location.create({
|
|
data: {
|
|
address: '123 Main St, Edmonton, AB',
|
|
latitude: 53.5461,
|
|
longitude: -113.4938,
|
|
geocodeProvider: GeocodeProvider.GOOGLE,
|
|
geocodeConfidence: 95,
|
|
buildingType: BuildingType.SINGLE_FAMILY,
|
|
totalUnits: 1,
|
|
createdByUserId: user.id,
|
|
history: {
|
|
create: {
|
|
userId: user.id,
|
|
action: LocationHistoryAction.GEOCODED,
|
|
metadata: { provider: 'google', confidence: 95 },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
```
|
|
|
|
### Find Locations in Bounding Box
|
|
```typescript
|
|
const locations = await prisma.location.findMany({
|
|
where: {
|
|
latitude: { gte: 53.5, lte: 53.6 },
|
|
longitude: { gte: -113.5, lte: -113.4 },
|
|
},
|
|
include: { addresses: true },
|
|
});
|
|
```
|
|
|
|
### Create Shift with Cut
|
|
```typescript
|
|
const shift = await prisma.shift.create({
|
|
data: {
|
|
title: 'Weekend Canvassing - Downtown',
|
|
date: new Date('2025-02-15'),
|
|
startTime: '10:00',
|
|
endTime: '14:00',
|
|
maxVolunteers: 10,
|
|
isPublic: true,
|
|
cutId: cut.id, // Assign to cut
|
|
},
|
|
});
|
|
```
|
|
|
|
### Public Shift Signup (Creates TEMP User)
|
|
```typescript
|
|
// 1. Create TEMP user with random password
|
|
const tempPassword = generatePassword(); // "SwiftEagle42"
|
|
const tempUser = await prisma.user.create({
|
|
data: {
|
|
email: 'volunteer@example.com',
|
|
password: await bcrypt.hash(tempPassword, 10),
|
|
name: 'Jane Volunteer',
|
|
role: UserRole.TEMP,
|
|
createdVia: UserCreatedVia.PUBLIC_SHIFT_SIGNUP,
|
|
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
|
|
expireDays: 30,
|
|
},
|
|
});
|
|
|
|
// 2. Create shift signup
|
|
await prisma.shiftSignup.create({
|
|
data: {
|
|
shiftId: shift.id,
|
|
userId: tempUser.id,
|
|
userEmail: 'volunteer@example.com',
|
|
userName: 'Jane Volunteer',
|
|
signupSource: SignupSource.PUBLIC,
|
|
},
|
|
});
|
|
|
|
// 3. Send confirmation email with temp password
|
|
await emailService.send({
|
|
template: 'shift-signup-confirmation',
|
|
variables: {
|
|
USER_NAME: 'Jane Volunteer',
|
|
SHIFT_TITLE: shift.title,
|
|
IS_NEW_USER: 'true',
|
|
TEMP_PASSWORD: tempPassword,
|
|
// ...
|
|
},
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Related Documentation
|
|
|
|
- [Schema Reference](../schema.md#map--locations) — Complete field listings
|
|
- [Database Overview](../index.md) — ER diagram
|
|
- [API Map Routes](../../api/map.md) — REST endpoints
|
|
- [Admin Locations Page](../../admin/locations.md) — Location management UI
|
|
- [Admin Cuts Page](../../admin/cuts.md) — Cut editor UI
|
|
- [Public Map Page](../../admin/public-map.md) — Public map UI
|