# 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 ```prisma 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 ```prisma 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 changed - `GEOCODED` — Address geocoded (auto or bulk geocoding) - `MOVED_ON_MAP` — Lat/lng changed via map drag - `DELETED` — 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:** ```bash curl -H "Authorization: Bearer " \ "http://api.cmlite.org/api/map/locations?page=1&limit=20&supportLevel=LEVEL_1&hasSign=true&confidenceLevel=high" ``` **Response (200 OK):** ```json { "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:** ```typescript 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:** ```typescript 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:** ```bash curl -H "Authorization: Bearer " \ "http://api.cmlite.org/api/map/locations/stats" ``` **Response (200 OK):** ```json { "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 count - `supportLevels` — Breakdown by support level - `signs` — Locations with `sign=true` - `geocoded` — Locations with lat/lng - `ungeocoded` — Locations without lat/lng - `confidence.high` — Geocode confidence ≥ 85 - `confidence.medium` — Geocode confidence 60-84 - `confidence.low` — Geocode confidence < 60 - `confidence.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:** ```json { "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: ```typescript 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):** ```json { "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: ```typescript // 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 | `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:** ```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:** ```bash curl -X POST -H "Authorization: Bearer " \ -F "file=@locations.csv" \ "http://api.cmlite.org/api/map/locations/import-csv" ``` **Response (200 OK):** ```json { "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 CSV - `success` — Successfully created locations - `warnings` — 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`/`longitude` columns: 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): `nar` or `standard` - `filterType` (optional): `none`, `cut`, `mapArea`, `city`, `province` - `cutId` (optional): Cut ID for `filterType=cut` - `filterCity` (optional): City name for `filterType=city` - `filterProvince` (optional): Province code for `filterType=province` (e.g., `ON`, `BC`) - `residentialOnly` (optional, default: false): Skip non-residential buildings (NAR only) - `deduplicateRadius` (optional, default: 5): Coordinate deduplication radius in meters - `skipGeocoding` (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):** ```bash curl -X POST -H "Authorization: Bearer " \ -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):** ```json { "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:** ```typescript 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:** 1. **Cut Filter (`filterType=cut`):** - Only imports locations inside specified cut polygon - Uses point-in-polygon ray-casting algorithm 2. **Map Area Filter (`filterType=mapArea`):** - Imports locations visible on current map view - Calculates bounding box from MapSettings (center, zoom) 3. **City Filter (`filterType=city`):** - Imports locations matching city name (case-insensitive) 4. **Province Filter (`filterType=province`):** - Imports locations matching province code (e.g., `ON`, `BC`) **Deduplication:** Prevents duplicate locations at same coordinates: ```typescript 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: ```typescript 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:** ```bash curl -H "Authorization: Bearer " \ "http://api.cmlite.org/api/map/locations/export-csv" \ -o locations.csv ``` **Response (200 OK):** CSV file with headers: ```csv 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:** ```json { "latitude": 43.6532, "longitude": -79.3832 } ``` **Response (200 OK):** ```json { "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:** ```bash # All locations curl -H "Authorization: Bearer " \ "http://api.cmlite.org/api/map/locations/all" # Bounding box (visible map area) curl -H "Authorization: Bearer " \ "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 number - `limit` (optional, default: 20): Results per page **Example Request:** ```bash curl -H "Authorization: Bearer " \ "http://api.cmlite.org/api/map/locations/clx1234567890/history?page=1&limit=20" ``` **Response (200 OK):** ```json { "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 created - `UPDATED` — Field changed (address, name, email, etc.) - `GEOCODED` — Auto-geocoded (address → lat/lng) - `MOVED_ON_MAP` — Coordinates changed via map drag - `DELETED` — 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:** ```bash curl "http://api.cmlite.org/api/public/map/locations?minLat=43.6&maxLat=43.7&minLng=-79.4&maxLng=-79.3" ``` **Response (200 OK):** ```json [ { "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:** ```typescript 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: ```typescript 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:** ```typescript 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:** ```typescript 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:** ```typescript const CSV_HEADER_MAP: Record = { 'address': 'address', 'street': 'address', 'street address': 'address', 'first name': 'firstName', 'firstname': 'firstName', // ... 50+ mappings }; ``` **Processing:** 1. Parse CSV with `csv-parse` library 2. Detect column mapping from headers 3. For each row: - Validate required fields (address) - Parse support level, sign boolean - Use provided lat/lng or geocode address - Create location in database 4. Return summary statistics --- ### locationsService.importBulk(buffer, userId, options, filters) Bulk import NAR or standard CSV with advanced filtering. **NAR Format Detection:** ```typescript 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** ```typescript // 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** ```typescript // 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** ```typescript 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:** ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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:** ```typescript const [locations, setLocations] = useState([]); 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=true` for NAR imports (coordinates included) - Split large files into smaller batches - Use cut filter to reduce import size - Increase `batchSize` parameter (1000 → 2000) --- ## Related Documentation - [Geocoding Service](/v2/backend/services/geocoding-service.md) - Multi-provider geocoding - [Cuts Module](/v2/backend/modules/cuts.md) - Polygon filtering - [Spatial Utils](/v2/backend/utilities/spatial-utils.md) - Point-in-polygon, bounds calculation - [Frontend: LocationsPage](/v2/frontend/pages/admin/locations-page.md) - Location management UI - [Frontend: Public Map Page](/v2/frontend/pages/public/map-page.md) - Public location map - [API Reference: Locations](/v2/api-reference/locations.md) - Complete endpoint reference - [Feature: Location Management](/v2/features/map/location-management.md) - Location management feature guide - [Feature: NAR Import](/v2/features/map/nar-import.md) - NAR bulk import guide