A production-ready MVP for analyzing property locations in New Zealand. Enter any NZ address and visualize nearby facilities (schools, bus stops) within a configurable radius, along with a hybrid location score powered by OpenStreetMap data.
Target users: Property buyers, real estate agents, renters in New Zealand.
- Docker & Docker Compose
- pnpm 9.15+
- Python 3.13 + uv
- Node.js 22+ (required for Next.js 16)
- A pre-downloaded LINZ NZ addresses ZIP at
docker/data/lds-nz-addresses-CSV.zip(see PostGIS Address Data)
# Clone repo
git clone git@github.com:sprajeesh/location-intelligence.git
cd location-intelligence
# Copy env files and fill in secrets
cp .env.example .env
# Edit .env and set: DB_USER, DB_PASSWORD, DATABASE_URL
echo "NEXT_PUBLIC_API_URL=http://localhost:8000" > apps/web/.env.local
# Copy backend env
cp .env apps/api/.env
# IMPORTANT: Prepare OSRM data (required, ~5 min, downloads 500MB NZ road data)
./scripts/setup-osrm.sh
# Build and start Docker services (Redis, PostGIS + LINZ data, OSRM)
# NOTE: PostGIS build loads ~2.6M NZ addresses from docker/data/ and builds indexes.
# First build takes 10–20 minutes.
docker compose build postgis
docker compose up -d
# Wait for services to be ready
# Check: docker compose ps (all should show "healthy")
# Backend (Terminal 1)
cd apps/api
uv sync
uv run uvicorn app.main:app --reload
# Frontend (Terminal 2)
cd apps/web
pnpm install
pnpm dev
# Open http://localhost:3000Verify everything is running:
# Backend health
curl http://localhost:8000/health
# PostGIS address search
curl "http://localhost:8000/search/address?q=Cuba+Street+Wellington"
# OSRM route
curl "http://localhost:5000/route/v1/driving/174.76,-36.85;174.77,-36.84?steps=false"Run diagnostics (checks all services):
./scripts/diagnose.shCommon issues:
| Issue | Solution |
|---|---|
docker compose build postgis fails |
Ensure docker/data/lds-nz-addresses-CSV.zip exists (see PostGIS Address Data) |
| PostGIS build takes long | Expected — downloads 2.6M addresses and builds indexes (~10–20 min) |
docker compose up fails on osrm |
Run ./scripts/setup-osrm.sh first (downloads NZ road data) |
| OSRM takes too long to start | Normal; OSRM loads large dataset into memory on startup (~1-2 min) |
Cannot GET / in browser |
Ensure pnpm dev (not pnpm build) in apps/web terminal |
| API errors / 404s | Check: curl http://localhost:8000/health and docker compose ps |
| "Cannot find module" errors | Run pnpm install in apps/web |
| "API_URL is not defined" | Add .env.local to apps/web with NEXT_PUBLIC_API_URL=http://localhost:8000 |
| CORS errors in console | Backend needs running; FastAPI CORS allows localhost:3000 |
┌─────────────────────────────────────────────────┐
│ Browser (Next.js) │
│ http://localhost:3000 │
└──────────────────────────┬──────────────────────┘
│
BFF Proxy Routes
│
┌──────────────────────────▼──────────────────────┐
│ FastAPI Backend │
│ http://localhost:8000 │
│ ┌─────────────────────────────────────────┐ │
│ │ Services Layer │ │
│ │ - Geocoding (PostGIS + Redis cache) │ │
│ │ - Facilities (Overpass, parallel) │ │
│ │ - Distance (OSRM + Haversine fallback) │ │
│ │ - Scoring (hybrid formula) │ │
│ └─────────────────────────────────────────┘ │
└────┬───────────────────────┬────────────────┬───┘
│ │ │
┌────▼──────────┐ ┌──────────▼──────┐ ┌──────▼──────┐
│ PostGIS │ │ Overpass API │ │ OSRM │
│ LINZ Address │ │ (Facilities) │ │ (Distance) │
│ :5432 │ │ │ │ :5000 │
└───────────────┘ └─────────────────┘ └─────────────┘
│
┌──────▼──────┐
│ Redis │
│ (Cache) │
│ :6379 │
└─────────────┘
| Layer | Tech | Purpose |
|---|---|---|
| Frontend | Next.js 16 (Active LTS) + React 19 + TypeScript | UI, address search, map interaction, i18n |
| BFF | Next.js API routes | Thin proxy to FastAPI, no auth/caching for MVP |
| Backend | FastAPI + Python 3.13 | Orchestration, external service calls, scoring |
| Map | React Leaflet + OpenStreetMap | Visualization |
| State | Zustand + React Query | Client-side UI state + server state |
| Services | Docker Compose | Redis (cache), PostGIS/LINZ (geocoding), OSRM (distance) |
location-intelligence/
├── apps/
│ ├── api/ # FastAPI backend
│ │ ├── app/
│ │ │ ├── main.py # App entry, lifespan, CORS
│ │ │ ├── api/ # Routers: /health, /search, /categories, /analyze
│ │ │ ├── services/ # Business logic
│ │ │ ├── clients/ # External API clients (Overpass, OSRM)
│ │ │ ├── repositories/ # Cache (Redis) + DB (PostGIS address repository)
│ │ │ ├── schemas/ # Pydantic models
│ │ │ ├── models/ # Domain models
│ │ │ └── config/ # Settings
│ │ ├── tests/ # pytest suite (45 tests)
│ │ ├── pyproject.toml
│ │ └── README.md
│ └── web/ # Next.js 16.2.9 frontend
│ ├── src/
│ │ ├── app/ # App Router pages + layouts
│ │ ├── components/ # Reusable UI components
│ │ ├── containers/ # Business-logic wrappers
│ │ ├── hooks/ # Custom React hooks
│ │ ├── services/ # API client
│ │ ├── store/ # Zustand store
│ │ ├── types/ # TypeScript types
│ │ └── i18n/ # next-intl config + translations
│ ├── package.json
│ └── README.md (planned)
├── docker/
│ ├── Dockerfile.postgis # PostGIS + LINZ NZ address data (layer 123113)
| └── sql/
│ ├── 01_schema.sql # addresses table definition
│ ├── 02_load.sql # COPY command for LINZ CSV
│ └── 03_post_load.sql # Geometry population + indexes
├── docker-compose.yml # Redis + PostGIS + OSRM
├── scripts/
│ ├── setup-osrm.sh # Download NZ OSRM road data
│ └── diagnose.sh # Service health diagnostics
├── turbo.json # Turborepo config
├── pnpm-workspace.yaml # pnpm workspaces
├── CLAUDE.md # Project memory + frontend spec
├── SPEC.md # Original product spec
└── .env.example # Environment variables template
- Autocomplete against official LINZ NZ Street Address dataset (~2.6M addresses)
- Trigram-indexed PostgreSQL search — handles macrons and partial matches
- 300ms debounce for responsive UX
- Top 5 suggestions, results cached for 30 days in Redis
- Schools (OSM:
amenity=school) - Bus Stops (OSM:
highway=bus_stoporpublic_transport=platform) - Future: Hospitals, Universities, Supermarkets, Parks, Libraries, Pharmacies
- Road distance via OSRM (driving or walking)
- Haversine fallback if OSRM unavailable
- Configurable radius: 1km, 5km, 10km, 20km, or custom
Hybrid formula combining proximity and facility density:
category_score = α × proximity_score + β × density_score
overall_score = weighted average of active categories (normalized)
- Education (Schools): 40% weight
- Transport (Bus Stops): 30% weight
- Healthcare, Shopping: reserved for future categories
- Leaflet-based with OpenStreetMap tiles
- Category-colored markers
- Marker clustering (>50 markers)
- Max 500 markers per search
- Popup with facility details on click
- English (en) — fully translated
- Māori (mi) — placeholder structure for future localization
- URL-based routing:
/en/...,/mi/...
- Glassmorphism design (semi-transparent panels + blur)
- Subtle micro-animations
- Responsive (desktop panel + mobile bottom sheet)
cp .env.example .envDocker build + services:
DB_USER=gisuser
DB_PASSWORD=your_secure_passwordBackend (FastAPI):
API_HOST=0.0.0.0
API_PORT=8000
DATABASE_URL=postgresql://gisuser:your_secure_password@localhost:5432/gis
OVERPASS_URL=https://overpass-api.de/api/interpreter
OSRM_URL=http://localhost:5000
REDIS_URL=redis://localhost:6379
SCORING_ALPHA=0.6
SCORING_BETA=0.4
SCORING_DENSITY_FACTOR=10Frontend (Next.js):
NEXT_PUBLIC_API_URL=http://localhost:8000
⚠️ Never commit.envto version control — it contains secrets. Only.env.example(with placeholder values) is committed.
# Build PostGIS image (only needed once, or when SQL/CSV changes)
docker compose build postgis
docker compose up -d
docker compose ps # Verify all services are healthyService endpoints:
- Redis:
localhost:6379 - PostGIS:
localhost:5432(database:gis, user:$DB_USER) - OSRM:
http://localhost:5000
cd apps/api
uv sync
uv run uvicorn app.main:app --reloadTest the API:
curl http://localhost:8000/health
curl http://localhost:8000/categories
curl "http://localhost:8000/search/address?q=Cuba+Street+Wellington"cd apps/web
pnpm install
pnpm dev# Backend
cd apps/api
uv run pytest # All tests
uv run pytest -xvs # Verbose
uv run ruff check app/ # Linting
# Frontend
cd apps/web
pnpm test
pnpm lintAddress search is powered by the LINZ NZ Street Address dataset (layer 123113), loaded into PostGIS at Docker image build time.
docker compose build postgisextractsdocker/data/lds-nz-addresses-CSV.zipand bakes the data into the image — no network access required at build time.- The
addressestable is indexed with a GIN trigram index onfull_address_ascii, enabling fastILIKEsearch that handles macrons (searchingOtahuhufindsŌtāhuhu). - The
GeocodingServicequeries PostGIS and caches results in Redis for 30 days.
Download the LINZ NZ Street Address dataset (layer 123113) from the LINZ Data Service and save the ZIP as docker/data/lds-nz-addresses-CSV.zip. This file is gitignored due to its size (~100MB).
# Replace docker/data/lds-nz-addresses-CSV.zip with a newer download, then:
docker compose build --no-cache postgis
docker compose up -d postgispsql -h localhost -U $DB_USER -d gis -c "SELECT count(*) FROM addresses;"
# Expected: ~2,600,000 rows
psql -h localhost -U $DB_USER -d gis \
-c "SELECT full_address, shape_x, shape_y FROM addresses WHERE full_address_ascii ILIKE 'cuba%' LIMIT 5;"Note on CSV column order:
docker/02_load.sqluses an explicit column list. If the LINZ export format changes, verify the CSV header order matches. Inspect with:head -1 /path/to/downloaded.csv
GET /healthResponse: {"status": "ok", "version": "1.0.0"}
GET /search/address?q=Queen%20Street&country=nzResponse:
[
{
"displayName": "123 Queen Street, Auckland",
"lat": -36.848,
"lon": 174.763
}
]GET /categoriesResponse:
[
{"id": "schools", "label": "Schools", "implemented": true, "color": "#F59E0B"},
{"id": "bus_stops", "label": "Bus Stops", "implemented": true, "color": "#14B8A6"},
...
]POST /location/analyzeRequest:
{
"address": "123 Queen Street, Auckland",
"lat": -36.848,
"lon": 174.763,
"radiusKm": 10,
"categories": ["schools", "bus_stops"],
"distanceMode": "driving"
}Response:
{
"location": {
"lat": -36.848,
"lon": 174.763,
"displayName": "123 Queen Street, Auckland"
},
"features": [
{
"id": "osm_node_12345",
"name": "Auckland Grammar School",
"category": "schools",
"lat": -36.852,
"lon": 174.77,
"distanceKm": 1.2
}
],
"score": {
"education": 72,
"healthcare": null,
"transport": 85,
"shopping": null,
"overall": 77,
"coverage": "2/4"
},
"warnings": []
}Full API documentation: Visit http://localhost:8000/docs (Swagger UI) when backend is running.
| Data | Cache Key | TTL |
|---|---|---|
| Geocoding results | geocode:{query_hash} |
30 days |
| Overpass facilities | overpass:{lat}:{lon}:{radius}:{category} |
24 hours |
| OSRM distances | osrm:{origin_hash}:{dest_hash}:{mode} |
24 hours |
- Scores are NOT cached (computed from cached facility data on-the-fly)
- Redis unavailable: graceful skip — API still works without caching
| Scenario | Behavior |
|---|---|
| Address not found | 404 with friendly message |
| No facilities in radius | 200 with empty features + suggestion |
| Overpass partial failure | Retry 2× with backoff → partial results + warnings |
| Overpass total failure | 503 with sanitized message |
| OSRM unavailable | Fallback to Haversine + warning |
| Rate limits | 429 with Retry-After header |
All error messages are sanitized — no stack traces, internal URLs, or service names leak to clients.
cd apps/api
uv run pytest # All 45 tests
uv run pytest tests/test_scoring.py -v # Scoring formula tests
uv run pytest tests/test_distance.py -v # Haversine tests
uv run pytest tests/test_api.py -v # Integration tests
uv run ruff check app/ # LintTest coverage:
- LocationScoringService formula (edge cases, zero facilities, null weights)
- Haversine distance calculation
/healthand/categoriesendpoint integration tests- Address search endpoint (mocked
AddressRepository)
Tests use a mocked AddressRepository — no live database connection required.
cd apps/web
pnpm test
pnpm lint| Metric | Target | Notes |
|---|---|---|
| Address autocomplete | < 1 sec | PostGIS trigram search + Redis cache |
| Full analysis | < 3 sec | Parallel Overpass queries |
| Map render | < 2 sec | Client-side only |
| Max markers | 500 | Clustering > 50 |
| Component | Target | Notes |
|---|---|---|
| Frontend | Vercel | next build → auto-deploy on main |
| Backend | AWS ECS Fargate | Docker image, FastAPI |
| Services | Managed | Redis (ElastiCache), PostGIS (container), OSRM (container) |
(Not yet deployed; MVP runs locally)
- ✅ Schools + Bus Stops (done)
- ⏳ All 14 facility categories (Hospitals, Universities, Supermarkets, etc.)
- ⏳ Authentication & user preferences
- ⏳ Save/share location reports
- ⏳ Property listing API integration
- ⏳ Crime data overlay
- ⏳ Drive-time isochrone visualization
- ⏳ Professional Māori translations
- ⏳ Navigation links (Google Maps directions)
- Branch naming:
feature/,bugfix/,docs/ - Commits: Atomic, logically grouped, conventional commit format
- Testing: All tests pass before pushing
- Linting:
ruff check(backend),eslint(frontend)
# Check PostGIS is ready
pg_isready -h localhost -p 5432 -U $DB_USER -d gis
# Check OSRM
curl http://localhost:5000/health
# Check Redis
redis-cli ping- Ensure
ssr: falseon MapView dynamic import - Check browser console for Leaflet CSS import errors
- Verify
NEXT_PUBLIC_API_URLenv var is set
- Verify PostGIS is running:
docker compose ps - Check data loaded:
psql -h localhost -U $DB_USER -d gis -c "SELECT count(*) FROM addresses;" - Check PostGIS logs:
docker compose logs postgis
- OSRM likely unavailable (check
docker compose logs osrm) - API returns warning: check response
warningsarray
MIT
- Project: Location Intelligence MVP
- Team: LINZ / Prajeesh Koothupalakkal
- Email: PKoothupalakkal@linz.govt.nz