36 KiB
Locations Module
Overview
The Locations module manages geographic locations for organizing campaigns, mapping volunteers, and tracking supporter data. It features multi-provider geocoding, NAR (National Address Register) bulk import with 2025 format support, CSV import/export, location history tracking, and comprehensive filtering with spatial queries.
Key Features:
- Location CRUD with automatic geocoding
- Multi-provider geocoding (Nominatim, Mapbox, ArcGIS, Photon, Google, LocationIQ)
- Batch geocoding with BullMQ queue integration
- NAR 2025 bulk import (Canadian electoral data with Lambert projection support)
- CSV import/export with flexible column mapping
- Location history tracking (audit trail for all changes)
- Reverse geocoding (lat/lng → address)
- Spatial filtering (cut polygons, bounding boxes, postal codes)
- Deduplication (coordinate-based with configurable radius)
- Support level tracking (LEVEL_1 through LEVEL_4)
- Sign tracking (lawn signs, sizes)
- Public map API (PII-filtered)
- Statistics dashboard (geocoding quality, provider distribution, confidence levels)
File Paths
| File | Purpose |
|---|---|
api/src/modules/map/locations/locations.routes.ts |
2 routers (admin + public) with 20 endpoints |
api/src/modules/map/locations/locations.service.ts |
Location business logic + geocoding + NAR import (1,100 lines) |
api/src/modules/map/locations/locations.schemas.ts |
Zod validation schemas |
api/src/modules/map/locations/nar-import.service.ts |
NAR import service (server-side streaming, legacy support) |
api/src/modules/map/locations/nar-import.routes.ts |
NAR import admin routes |
api/src/modules/map/locations/bulk-geocode.routes.ts |
Bulk geocoding queue routes |
api/src/modules/map/locations/bulk-geocode.schemas.ts |
Bulk geocoding schemas |
Database Models
Location
model Location {
id String @id @default(cuid())
address String
unitNumber String?
firstName String?
lastName String?
email String?
phone String?
supportLevel SupportLevel?
sign Boolean @default(false)
signSize String?
notes String? @db.Text
buildingNotes String? @db.Text
// Geocoding
latitude Float?
longitude Float?
geocodeConfidence Int?
geocodeProvider GeocodeProvider?
// NAR fields (2025 format support)
postalCode String?
province String?
federalDistrict String?
buildingUse Int? // 1=Residential, 2=Commercial, 3=Mixed
// Audit
createdByUserId String?
updatedByUserId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
createdByUser User? @relation("LocationCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
updatedByUser User? @relation("LocationUpdater", fields: [updatedByUserId], references: [id], onDelete: SetNull)
history LocationHistory[]
@@index([latitude, longitude])
@@index([supportLevel])
@@index([sign])
@@index([geocodeConfidence])
@@map("locations")
}
enum SupportLevel {
LEVEL_1 // Strong support
LEVEL_2 // Moderate support
LEVEL_3 // Undecided
LEVEL_4 // Opposed
}
enum GeocodeProvider {
NOMINATIM
MAPBOX
ARCGIS
PHOTON
GOOGLE
LOCATIONIQ
UNKNOWN
}
LocationHistory
model LocationHistory {
id String @id @default(cuid())
locationId String
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
action LocationHistoryAction
field String?
oldValue String?
newValue String?
metadata Json?
createdAt DateTime @default(now())
@@index([locationId])
@@index([userId])
@@index([action])
@@map("location_history")
}
enum LocationHistoryAction {
CREATED
UPDATED
GEOCODED
MOVED_ON_MAP
DELETED
}
History Tracking:
- All location changes recorded with before/after values
CREATED— Location created (manual or import)UPDATED— Field changedGEOCODED— Address geocoded (auto or bulk geocoding)MOVED_ON_MAP— Lat/lng changed via map dragDELETED— Location deleted
API Endpoints
Admin Endpoints (Authentication Required)
| Method | Path | Description |
|---|---|---|
| GET | /api/map/locations |
List locations (paginated, filtered) |
| GET | /api/map/locations/stats |
Location statistics |
| GET | /api/map/locations/export-csv |
Export CSV download |
| GET | /api/map/locations/all |
All geocoded locations for map (admin, 5000 limit) |
| GET | /api/map/locations/:id |
Get single location |
| GET | /api/map/locations/:id/history |
Get location edit history |
| POST | /api/map/locations |
Create location (auto-geocodes if no lat/lng) |
| POST | /api/map/locations/geocode |
Geocode single address |
| POST | /api/map/locations/geocode-missing |
Geocode all ungeocoded locations |
| POST | /api/map/locations/import-csv |
Upload + import CSV (10MB limit) |
| POST | /api/map/locations/import-bulk |
Bulk import NAR or CSV (100MB limit, 5min timeout) |
| POST | /api/map/locations/reverse-geocode |
Reverse geocode lat/lng to address |
| POST | /api/map/locations/bulk-delete |
Delete multiple locations |
| PUT | /api/map/locations/:id |
Update location |
| DELETE | /api/map/locations/:id |
Delete location |
Admin Roles: SUPER_ADMIN, MAP_ADMIN
Public Endpoints (No Authentication)
| Method | Path | Description |
|---|---|---|
| GET | /api/map/locations/public |
Public locations for map (PII-filtered, 5000 limit) |
Admin Endpoint Details
GET /api/map/locations
List locations with pagination, search, and filtering.
Query Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| page | number | No | 1 | Page number |
| limit | number | No | 20 | Results per page (max 100) |
| search | string | No | - | Search address, first/last name, email |
| supportLevel | SupportLevel | No | - | Filter by support level |
| hasSign | boolean | No | - | Filter by sign presence |
| confidenceLevel | string | No | - | Filter by geocode confidence: high (85+), medium (60-84), low (<60), none (0 or null) |
| sortBy | string | No | createdAt | Sort field: createdAt, address, supportLevel |
| sortOrder | string | No | desc | Sort order: asc, desc |
Example Request:
curl -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/map/locations?page=1&limit=20&supportLevel=LEVEL_1&hasSign=true&confidenceLevel=high"
Response (200 OK):
{
"locations": [
{
"id": "clx1234567890",
"address": "123 Main St, Toronto, ON",
"unitNumber": "Apt 4",
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com",
"phone": "416-555-1234",
"supportLevel": "LEVEL_1",
"sign": true,
"signSize": "Large",
"notes": "Willing to volunteer",
"buildingNotes": "Apartment building, intercom required",
"latitude": 43.6532,
"longitude": -79.3832,
"geocodeConfidence": 95,
"geocodeProvider": "NOMINATIM",
"postalCode": "M5H 2N2",
"province": "ON",
"federalDistrict": "Toronto Centre",
"buildingUse": 1,
"createdByUserId": "clxUser123",
"updatedByUserId": null,
"createdAt": "2026-02-08T12:00:00.000Z",
"updatedAt": "2026-02-08T12:00:00.000Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 342,
"totalPages": 18
}
}
Search Logic:
if (search) {
where.OR = [
{ address: { contains: search, mode: 'insensitive' } },
{ firstName: { contains: search, mode: 'insensitive' } },
{ lastName: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
];
}
Confidence Level Filtering:
if (confidenceLevel === 'high') {
where.geocodeConfidence = { gte: 85 };
} else if (confidenceLevel === 'medium') {
where.geocodeConfidence = { gte: 60, lt: 85 };
} else if (confidenceLevel === 'low') {
where.geocodeConfidence = { lt: 60, gt: 0 };
} else if (confidenceLevel === 'none') {
where.OR = [{ geocodeConfidence: null }, { geocodeConfidence: 0 }];
}
GET /api/map/locations/stats
Get aggregate statistics for locations.
Example Request:
curl -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/map/locations/stats"
Response (200 OK):
{
"total": 1247,
"supportLevels": {
"LEVEL_1": 342,
"LEVEL_2": 189,
"LEVEL_3": 276,
"LEVEL_4": 98,
"NONE": 342
},
"signs": 142,
"geocoded": 1189,
"ungeocoded": 58,
"confidence": {
"high": 892,
"medium": 213,
"low": 84,
"none": 58,
"average": 87
},
"providers": {
"nominatim": 654,
"mapbox": 312,
"arcgis": 98,
"photon": 76,
"google": 34,
"locationiq": 15,
"manual": 58
}
}
Field Descriptions:
total— Total location countsupportLevels— Breakdown by support levelsigns— Locations withsign=truegeocoded— Locations with lat/lngungeocoded— Locations without lat/lngconfidence.high— Geocode confidence ≥ 85confidence.medium— Geocode confidence 60-84confidence.low— Geocode confidence < 60confidence.none— No geocode confidence (0 or null)confidence.average— Average geocode confidence (excludes 0/null)providers— Breakdown by geocode provider
POST /api/map/locations
Create new location with automatic geocoding.
Request Body:
{
"address": "123 Main St, Toronto, ON",
"unitNumber": "Apt 4",
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com",
"phone": "416-555-1234",
"supportLevel": "LEVEL_1",
"sign": true,
"signSize": "Large",
"notes": "Willing to volunteer",
"buildingNotes": "Apartment building, intercom required"
}
Response (201 Created):
Returns created location object.
Auto-Geocoding:
If address provided and no latitude/longitude, automatically geocodes:
if (data.address && data.latitude == null && data.longitude == null) {
const result = await geocodingService.geocode(data.address);
if (result) {
createData.latitude = result.latitude;
createData.longitude = result.longitude;
createData.geocodeConfidence = result.confidence;
createData.geocodeProvider = result.provider;
}
}
History Tracking:
Creates LocationHistory record with action GEOCODED (if geocoded) or CREATED (if manual coordinates).
PUT /api/map/locations/:id
Update location. Re-geocodes if address changes without explicit lat/lng.
Request Body (Partial):
{
"address": "456 Oak St, Toronto, ON",
"supportLevel": "LEVEL_2"
}
Response (200 OK):
Returns updated location object.
Smart Geocoding:
- If address changes and no explicit lat/lng provided: re-geocode automatically
- If lat/lng provided: use provided coordinates (manual override)
History Tracking:
Records field changes with before/after values:
// Track changes
const changes: { field: string; oldValue: unknown; newValue: unknown }[] = [];
if (data.address && data.address !== existing.address) {
changes.push({ field: 'address', oldValue: existing.address, newValue: data.address });
}
// Determine action based on changes
let action: LocationHistoryAction = LocationHistoryAction.UPDATED;
if (data.latitude !== undefined && data.latitude !== existing.latitude) {
action = LocationHistoryAction.MOVED_ON_MAP; // Explicit coordinate change (map drag)
}
if (address changed && auto-geocoded) {
action = LocationHistoryAction.GEOCODED;
}
POST /api/map/locations/import-csv
Upload and import CSV file with flexible column mapping.
Multipart Form Data:
file(required): CSV file (max 10MB)
Supported Column Names (Case-Insensitive):
| Field | Column Names |
|---|---|
| address | address, street, street address |
| firstName | first name, firstname, first |
| lastName | last name, lastname, last |
email, e-mail |
|
| phone | phone, telephone, tel, phone number |
| unitNumber | unit, unit number, apt, apartment, suite |
| supportLevel | support level, supportlevel, support, level |
| sign | sign, lawn sign |
| signSize | sign size, signsize |
| notes | notes, note, comments |
| latitude | latitude, lat |
| longitude | longitude, lng, lon |
Example CSV:
address,first name,last name,email,phone,support level,sign
"123 Main St, Toronto, ON",John,Doe,john@example.com,416-555-1234,LEVEL_1,true
"456 Oak St, Toronto, ON",Jane,Smith,jane@example.com,416-555-5678,LEVEL_2,false
Example Request:
curl -X POST -H "Authorization: Bearer <token>" \
-F "file=@locations.csv" \
"http://api.cmlite.org/api/map/locations/import-csv"
Response (200 OK):
{
"total": 1000,
"success": 942,
"warnings": 34,
"failed": 24,
"errors": [
"Row 12: Missing address",
"Row 45: Invalid email format",
"Row 89: Geocoding failed"
]
}
Field Descriptions:
total— Total rows in CSVsuccess— Successfully created locationswarnings— Created but geocoding failed (no lat/lng)failed— Failed to create (validation errors)errors— First 50 error messages (row numbers 1-indexed)
Geocoding:
- If CSV has
latitude/longitudecolumns: uses provided coordinates - Otherwise: auto-geocodes each address (slow for large files, consider NAR import for bulk)
POST /api/map/locations/import-bulk
Bulk import NAR (National Address Register) or standard CSV with advanced filtering.
Multipart Form Data:
file(required): CSV file (max 100MB)format(required):narorstandardfilterType(optional):none,cut,mapArea,city,provincecutId(optional): Cut ID forfilterType=cutfilterCity(optional): City name forfilterType=cityfilterProvince(optional): Province code forfilterType=province(e.g.,ON,BC)residentialOnly(optional, default: false): Skip non-residential buildings (NAR only)deduplicateRadius(optional, default: 5): Coordinate deduplication radius in metersskipGeocoding(optional, default: true): Skip geocoding (NAR files have coordinates)batchSize(optional, default: 1000): Database batch insert size
Request Timeout: 5 minutes (extended for large files)
Example Request (NAR Import with Cut Filter):
curl -X POST -H "Authorization: Bearer <token>" \
-F "file=@Address_24_part_1.csv" \
-F "format=nar" \
-F "filterType=cut" \
-F "cutId=clxCut123" \
-F "residentialOnly=true" \
-F "deduplicateRadius=5" \
"http://api.cmlite.org/api/map/locations/import-bulk"
Response (200 OK):
{
"total": 50000,
"created": 12847,
"skippedDuplicate": 1243,
"skippedOutOfBounds": 34892,
"skippedInvalid": 1018,
"errors": [
"Row 234: Invalid coordinates",
"Row 1892: Missing civic number"
]
}
NAR Format Support:
2025 NAR Format (Recommended):
- Address File Columns:
CIVIC_NO,CIVIC_NO_SUFFIX,OFFICIAL_STREET_NAME,OFFICIAL_STREET_TYPE,OFFICIAL_STREET_DIR,APT_NO_LABEL,BG_X,BG_Y,MAIL_MUN_NAME,MAIL_PROV_ABVN,MAIL_POSTAL_CODE,FED_ENG_NAME,BU_USE - Location File Columns:
BG_LATITUDE,BG_LONGITUDE(WGS84),LOC_GUID - Coordinate Systems:
BG_X/BG_Y— EPSG:3347 Lambert Conformal Conic (converted to WGS84)BG_LATITUDE/BG_LONGITUDE— WGS84 (used directly)
Legacy NAR Format (Backward Compatible):
- Columns:
STR_NBR,STR_NME,STR_TYP,STR_DIR,LAT,LNG,MUN_NME,PRV_NME
Auto-Detection:
If 3+ NAR-specific columns detected, automatically treats as NAR format.
Lambert Projection Conversion:
import proj4 from 'proj4';
// Define EPSG:3347 (Statistics Canada Lambert Conformal Conic)
proj4.defs('EPSG:3347', '+proj=lcc +lat_1=49 +lat_2=77 +lat_0=63.390675 +lon_0=-91.86666666666666 +x_0=6200000 +y_0=3000000 +ellps=GRS80 +units=m +no_defs');
function lambertToLatLng(bgX: number, bgY: number): [number, number] {
const [lng, lat] = proj4('EPSG:3347', 'EPSG:4326', [bgX, bgY]);
return [lat, lng];
}
Filtering Options:
-
Cut Filter (
filterType=cut):- Only imports locations inside specified cut polygon
- Uses point-in-polygon ray-casting algorithm
-
Map Area Filter (
filterType=mapArea):- Imports locations visible on current map view
- Calculates bounding box from MapSettings (center, zoom)
-
City Filter (
filterType=city):- Imports locations matching city name (case-insensitive)
-
Province Filter (
filterType=province):- Imports locations matching province code (e.g.,
ON,BC)
- Imports locations matching province code (e.g.,
Deduplication:
Prevents duplicate locations at same coordinates:
const coordKey = `${roundCoord(lat, 5)}:${roundCoord(lng, 5)}`; // 5 decimal places = ~1.1m precision
if (existingCoords.has(coordKey) || inFileCoords.has(coordKey)) {
skippedDuplicate++;
continue;
}
Batch Processing:
Inserts locations in batches (default 1000) for performance:
const batch: Prisma.LocationCreateManyInput[] = [];
// ... collect locations ...
if (batch.length >= options.batchSize) {
await prisma.location.createMany({ data: batch, skipDuplicates: true });
batch.length = 0;
}
GET /api/map/locations/export-csv
Export locations as CSV download.
Example Request:
curl -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/map/locations/export-csv" \
-o locations.csv
Response (200 OK):
CSV file with headers:
address,firstName,lastName,email,phone,unitNumber,supportLevel,sign,signSize,notes,latitude,longitude,geocodeConfidence,geocodeProvider,createdAt
"123 Main St, Toronto, ON",John,Doe,john@example.com,416-555-1234,Apt 4,LEVEL_1,Yes,Large,Willing to volunteer,43.6532,-79.3832,95,NOMINATIM,2026-02-08T12:00:00.000Z
POST /api/map/locations/reverse-geocode
Reverse geocode coordinates to address.
Request Body:
{
"latitude": 43.6532,
"longitude": -79.3832
}
Response (200 OK):
{
"address": "123 Main St, Toronto, ON M5H 2N2, Canada",
"provider": "NOMINATIM",
"confidence": 85
}
Use Cases:
- Click-to-add location on map (get address from coordinates)
- Move location on map (update address after drag)
- Verify coordinates match expected address
GET /api/map/locations/all
Get all geocoded locations for admin map view.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
| minLat | number | Minimum latitude (bounding box) |
| maxLat | number | Maximum latitude |
| minLng | number | Minimum longitude |
| maxLng | number | Maximum longitude |
Example Request:
# All locations
curl -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/map/locations/all"
# Bounding box (visible map area)
curl -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/map/locations/all?minLat=43.6&maxLat=43.7&minLng=-79.4&maxLng=-79.3"
Response (200 OK):
Returns array of location objects (max 5000).
Safety Limit:
If result hits 5000 locations, adds header X-Location-Limit-Hit: true to warn client.
GET /api/map/locations/:id/history
Get location edit history with audit trail.
Query Parameters:
page(optional, default: 1): Page numberlimit(optional, default: 20): Results per page
Example Request:
curl -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/map/locations/clx1234567890/history?page=1&limit=20"
Response (200 OK):
{
"history": [
{
"id": "clxHistory123",
"locationId": "clx1234567890",
"userId": "clxUser123",
"user": {
"id": "clxUser123",
"email": "admin@example.com",
"name": "Admin User",
"role": "SUPER_ADMIN"
},
"action": "MOVED_ON_MAP",
"field": "latitude",
"oldValue": "43.6532",
"newValue": "43.6540",
"metadata": null,
"createdAt": "2026-02-11T12:00:00.000Z"
},
{
"id": "clxHistory124",
"locationId": "clx1234567890",
"userId": "clxUser123",
"user": {...},
"action": "GEOCODED",
"field": "latitude",
"oldValue": null,
"newValue": "43.6532",
"metadata": {
"provider": "NOMINATIM",
"confidence": 95,
"geocoded": true
},
"createdAt": "2026-02-08T12:00:00.000Z"
},
{
"id": "clxHistory125",
"locationId": "clx1234567890",
"userId": "clxUser123",
"user": {...},
"action": "CREATED",
"field": null,
"oldValue": null,
"newValue": null,
"metadata": null,
"createdAt": "2026-02-08T12:00:00.000Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 7,
"totalPages": 1
}
}
History Actions:
CREATED— Location createdUPDATED— Field changed (address, name, email, etc.)GEOCODED— Auto-geocoded (address → lat/lng)MOVED_ON_MAP— Coordinates changed via map dragDELETED— Location deleted (orphaned history records)
Public Endpoint Details
GET /api/map/locations/public
Get locations for public map (PII-filtered).
Query Parameters:
minLat,maxLat,minLng,maxLng(optional): Bounding box
Example Request:
curl "http://api.cmlite.org/api/public/map/locations?minLat=43.6&maxLat=43.7&minLng=-79.4&maxLng=-79.3"
Response (200 OK):
[
{
"id": "clx1234567890",
"latitude": 43.6532,
"longitude": -79.3832,
"supportLevel": "LEVEL_1",
"sign": true,
"signSize": "Large",
"unitNumber": "Apt 4",
"address": "123 Main St, Toronto, ON"
}
]
PII Filtering:
Only returns non-sensitive fields:
- Included:
id,latitude,longitude,supportLevel,sign,signSize,unitNumber,address - Excluded:
firstName,lastName,email,phone,notes,buildingNotes,geocodeConfidence,geocodeProvider,createdByUserId,postalCode,province,federalDistrict,buildingUse
Service Functions
locationsService.create(data, userId)
Create location with auto-geocoding.
Auto-Geocoding Logic:
if (data.address && data.latitude == null && data.longitude == null) {
const result = await geocodingService.geocode(data.address);
if (result) {
createData.latitude = result.latitude;
createData.longitude = result.longitude;
createData.geocodeConfidence = result.confidence;
createData.geocodeProvider = result.provider;
}
}
History Recording:
Creates history record in transaction:
const location = await prisma.$transaction(async (tx) => {
const newLocation = await tx.location.create({ data: createData });
await tx.locationHistory.create({
data: {
locationId: newLocation.id,
userId,
action: geocodeMetadata ? LocationHistoryAction.GEOCODED : LocationHistoryAction.CREATED,
metadata: geocodeMetadata,
},
});
return newLocation;
});
locationsService.update(id, data, userId)
Update location with smart geocoding and history tracking.
Smart Geocoding:
- If address changes and no explicit lat/lng: re-geocode
- If lat/lng provided: use provided coordinates (manual override)
Action Detection:
let action: LocationHistoryAction = LocationHistoryAction.UPDATED;
// Explicit coordinate change (map drag)
if (data.latitude !== undefined && data.latitude !== existing.latitude) {
action = LocationHistoryAction.MOVED_ON_MAP;
}
// Auto-geocode on address change
if (data.address && data.address !== existing.address && !data.latitude && !data.longitude) {
const result = await geocodingService.geocode(data.address);
if (result) {
updateData.latitude = result.latitude;
updateData.longitude = result.longitude;
action = LocationHistoryAction.GEOCODED;
}
}
Change Tracking:
const changes: { field: string; oldValue: unknown; newValue: unknown }[] = [];
const fieldsToTrack = ['address', 'firstName', 'lastName', 'email', 'phone', 'unitNumber', 'supportLevel', 'sign', 'signSize', 'notes'];
for (const field of fieldsToTrack) {
if (data[field] !== undefined && data[field] !== existing[field]) {
changes.push({ field, oldValue: existing[field], newValue: data[field] });
}
}
// Record all changes in transaction
await tx.locationHistory.createMany({ data: historyRecords });
locationsService.importFromCsv(buffer, userId)
Import CSV with flexible column mapping.
Column Mapping:
const CSV_HEADER_MAP: Record<string, keyof CsvRow> = {
'address': 'address',
'street': 'address',
'street address': 'address',
'first name': 'firstName',
'firstname': 'firstName',
// ... 50+ mappings
};
Processing:
- Parse CSV with
csv-parselibrary - Detect column mapping from headers
- For each row:
- Validate required fields (address)
- Parse support level, sign boolean
- Use provided lat/lng or geocode address
- Create location in database
- Return summary statistics
locationsService.importBulk(buffer, userId, options, filters)
Bulk import NAR or standard CSV with advanced filtering.
NAR Format Detection:
function detectNarFormat(headers: string[]): boolean {
const NAR_DETECT_COLUMNS = [
'CIVIC_NO', 'OFFICIAL_STREET_NAME', 'BG_X', 'BG_Y', // 2025 format
'STR_NBR', 'STR_NME', 'LAT', 'LNG', // Legacy format
];
const normalizedHeaders = headers.map((h) => h.trim().toUpperCase());
let matchCount = 0;
for (const col of NAR_DETECT_COLUMNS) {
if (normalizedHeaders.includes(col)) matchCount++;
}
return matchCount >= 3; // At least 3 NAR columns
}
3-Phase Processing:
Phase 1: Parse & Filter
// Parse all records
for (const record of records) {
// Build address from NAR fields
const civicNo = getValue('CIVIC_NO');
const streetName = getValue('STREET_NAME');
const address = [civicNo, streetName, ...].join(' ');
// Apply filters
if (filters?.city && !matchesCity(address, filters.city)) {
skippedOutOfBounds++;
continue;
}
// Residential filter
if (options.residentialOnly && buildingUse === 3) {
skippedOutOfBounds++;
continue;
}
parsedRecords.push({ address, lat, lng, needsGeocoding });
}
Phase 2: Batch Geocode
// Collect addresses needing geocoding
const addressesToGeocode: string[] = parsedRecords
.filter(r => r.needsGeocoding)
.map(r => r.address);
// Batch geocode (parallel)
const geocodeResults = await geocodingService.geocodeBatch(addressesToGeocode);
Phase 3: Create Records
const batch: Prisma.LocationCreateManyInput[] = [];
for (const parsed of parsedRecords) {
// Apply geocoding result
if (parsed.needsGeocoding) {
const result = geocodeResults[geocodeIndex];
if (result) {
lat = result.latitude;
lng = result.longitude;
}
}
// Cut polygon filter
if (filters?.cutPolygon) {
if (!isPointInPolygon(lat, lng, cutPolygon)) {
skippedOutOfBounds++;
continue;
}
}
// Deduplication
if (existingCoords.has(coordKey)) {
skippedDuplicate++;
continue;
}
batch.push({ address, lat, lng, ... });
// Flush batch
if (batch.length >= options.batchSize) {
await prisma.location.createMany({ data: batch });
batch.length = 0;
}
}
locationsService.exportToCsv(filters?)
Export locations as CSV.
CSV Generation:
import { stringify } from 'csv-stringify/sync';
const rows = locations.map((loc) => ({
address: loc.address || '',
firstName: loc.firstName || '',
lastName: loc.lastName || '',
email: loc.email || '',
phone: loc.phone || '',
unitNumber: loc.unitNumber || '',
supportLevel: loc.supportLevel || '',
sign: loc.sign ? 'Yes' : 'No',
signSize: loc.signSize || '',
notes: loc.notes || '',
latitude: loc.latitude?.toString() || '',
longitude: loc.longitude?.toString() || '',
geocodeConfidence: loc.geocodeConfidence?.toString() || '',
geocodeProvider: loc.geocodeProvider || '',
createdAt: loc.createdAt.toISOString(),
}));
return stringify(rows, { header: true });
Validation Schemas
Create Location Schema
export const createLocationSchema = z.object({
address: z.string().min(1, 'Address is required'),
firstName: z.string().optional(),
lastName: z.string().optional(),
email: z.string().email().optional().or(z.literal('')),
phone: z.string().optional(),
unitNumber: z.string().optional(),
supportLevel: z.nativeEnum(SupportLevel).optional(),
sign: z.boolean().optional().default(false),
signSize: z.string().optional(),
notes: z.string().optional(),
buildingNotes: z.string().max(2000).optional(),
latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(),
});
Bulk Import Schema
export const bulkImportSchema = z.object({
format: z.enum(['standard', 'nar']).default('standard'),
filterType: z.enum(['none', 'cut', 'mapArea', 'city', 'province']).default('none'),
cutId: z.string().optional(),
filterCity: z.string().optional(),
filterProvince: z.string().optional(),
residentialOnly: z.coerce.boolean().default(false),
deduplicateRadius: z.coerce.number().min(0).max(100).default(5),
skipGeocoding: z.coerce.boolean().default(true),
batchSize: z.coerce.number().int().min(100).max(5000).default(1000),
});
Code Examples
Admin: Create Location with Auto-Geocoding
import { api } from '@/lib/api';
import { message } from 'antd';
const createLocation = async () => {
try {
const { data } = await api.post('/api/map/locations', {
address: '123 Main St, Toronto, ON',
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
supportLevel: 'LEVEL_1',
sign: true,
});
message.success('Location created and geocoded');
console.log(`Created at: ${data.latitude}, ${data.longitude}`);
console.log(`Confidence: ${data.geocodeConfidence}%`);
} catch (error) {
message.error('Failed to create location');
}
};
Admin: Import NAR File with Cut Filter
import { api } from '@/lib/api';
import { message } from 'antd';
const importNAR = async (file: File, cutId: string) => {
const formData = new FormData();
formData.append('file', file);
formData.append('format', 'nar');
formData.append('filterType', 'cut');
formData.append('cutId', cutId);
formData.append('residentialOnly', 'true');
formData.append('deduplicateRadius', '5');
try {
const { data } = await api.post('/api/map/locations/import-bulk', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 300000, // 5 minutes
});
message.success(`Created ${data.created} locations`);
console.log(`Skipped ${data.skippedDuplicate} duplicates`);
console.log(`Skipped ${data.skippedOutOfBounds} out of bounds`);
} catch (error) {
message.error('NAR import failed');
}
};
Admin: Export Locations
import { api } from '@/lib/api';
import { message } from 'antd';
const exportLocations = async () => {
try {
const { data } = await api.get('/api/map/locations/export-csv', {
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', 'locations.csv');
document.body.appendChild(link);
link.click();
link.remove();
message.success('Locations exported');
} catch (error) {
message.error('Export failed');
}
};
Frontend Integration
The LocationsPage component (admin/src/pages/LocationsPage.tsx) provides:
- Location table with pagination (20 results/page)
- Search (address, name, email)
- Filters (support level, sign, confidence level)
- Sorting (createdAt, address, supportLevel)
- Statistics dashboard (total, support levels, signs, geocoded, confidence breakdown, provider distribution)
- Create location modal (form with auto-geocoding preview)
- Edit location modal (pre-populated form)
- Delete location action
- Bulk delete (select multiple rows)
- CSV import (10MB limit)
- NAR bulk import (100MB limit, cut/city/province filters)
- CSV export (download button)
- Geocode missing button (batch geocodes all ungeocoded)
- Location history drawer (audit trail with user, action, field changes)
- Map integration (shows all geocoded locations, click-to-add, drag-to-move)
State Management:
const [locations, setLocations] = useState<Location[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });
const [filters, setFilters] = useState({ search: '', supportLevel: null, hasSign: null, confidenceLevel: null });
const [stats, setStats] = useState({ total: 0, supportLevels: {}, signs: 0, geocoded: 0, ungeocoded: 0, confidence: {}, providers: {} });
Performance Considerations
Batch Processing:
- NAR import uses 1000-record batches (configurable)
- Reduces transaction overhead
- Improves import speed (10,000+ locations/minute)
Deduplication:
- Coordinate-based (5 decimal places = ~1.1m precision)
- In-memory Set for fast lookups
- Prevents duplicate imports within same file
Indexing:
@@index([latitude, longitude])— Fast map bounds queries@@index([supportLevel])— Fast filtering by support level@@index([sign])— Fast sign filtering@@index([geocodeConfidence])— Fast confidence filtering
Safety Limits:
- Map queries limited to 5000 locations
- CSV import limited to 10MB
- Bulk import limited to 100MB (5-minute timeout)
- Bulk import warning header when limit hit
Geocoding:
- Auto-geocodes on create/update (individual addresses)
- Batch geocoding for bulk imports (parallel processing)
- Uses BullMQ queue for background geocoding (separate service)
Troubleshooting
Issue: CSV import fails with "Invalid CSV file format"
Cause: CSV not UTF-8 encoded or has malformed rows
Solution:
- Save CSV as UTF-8 in Excel/LibreOffice
- Ensure no missing quote delimiters
- Remove empty rows at end of file
Issue: NAR import skips all records (skippedOutOfBounds = total)
Cause: Cut/city/province filter doesn't match any records
Solution:
- Verify cut ID is correct
- Check city/province spelling matches NAR data (case-insensitive)
- Try without filters first to verify file format
Issue: Geocoding confidence is low (<60) for many locations
Cause: Incomplete addresses or geocoding provider limitations
Solution:
- Use NAR import (has pre-geocoded coordinates)
- Add city/province to addresses
- Try different geocoding provider (see settings)
- Use "Geocode Missing" button to retry with fallback providers
Issue: Bulk import times out after 5 minutes
Cause: File too large or too many locations to geocode
Solution:
- Set
skipGeocoding=truefor NAR imports (coordinates included) - Split large files into smaller batches
- Use cut filter to reduce import size
- Increase
batchSizeparameter (1000 → 2000)
Related Documentation
- Geocoding Service - Multi-provider geocoding
- Cuts Module - Polygon filtering
- Spatial Utils - Point-in-polygon, bounds calculation
- Frontend: LocationsPage - Location management UI
- Frontend: Public Map Page - Public location map
- API Reference: Locations - Complete endpoint reference
- Feature: Location Management - Location management feature guide
- Feature: NAR Import - NAR bulk import guide