1353 lines
36 KiB
Markdown
1353 lines
36 KiB
Markdown
# 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 <token>" \
|
|
"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 <token>" \
|
|
"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 <token>" \
|
|
-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 <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):**
|
|
|
|
```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 <token>" \
|
|
"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 <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 number
|
|
- `limit` (optional, default: 20): Results per page
|
|
|
|
**Example Request:**
|
|
|
|
```bash
|
|
curl -H "Authorization: Bearer <token>" \
|
|
"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<string, keyof CsvRow> = {
|
|
'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<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=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
|