* Listing detail: drop the new <NearbyPoiSidebar> below the price card
with default 1.5km radius and 6 categories (school/secondary/hospital/
market/bank/metro). Reads property.lat/lng — no-op when unset.
* KCN detail: same component but 3km radius with the categories that
matter for industrial parks (hospital/bank/gas/bus/metro/police).
* New <PoiSearchFilter> widget for the search page: pill button →
popover with radius dropdown (300m..5km), 3 quick presets ("Family",
"Commute", "Convenience"), and 6 grouped category checkboxes. Wires
to a `PoiNearbyConstraint` value so callers can pass it into search
filters when they're ready.
* docs/osm-data-model.md: canonical reference for every OSM-sourced
table, sync cadence, quality gates, runbook for ops, and a clear
"how to add a new POI category" guide.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4.7 KiB
4.7 KiB
OSM Data Model — GoodGo Platform
This document is the canonical reference for every OpenStreetMap-sourced table in the GoodGo database, the sync pipelines that populate them, and the query patterns that use them.
Tables at a glance
| Table | Source | Geometry | Sync cadence | Used by |
|---|---|---|---|---|
vn_provinces |
OSM boundary=administrative + admin_level=4 |
MultiPolygon | Weekly (Mon 02:30 ICT) | GeoLookupService, KCN sync, address auto-fill |
vn_districts |
OSM admin_level=6 |
MultiPolygon | Weekly (Wed 02:30 ICT) | Same as above |
vn_wards |
OSM admin_level=8 |
MultiPolygon | Weekly (Sat 02:30 ICT) | Same as above |
Poi |
OSM nodes/ways/relations matching 20 category selectors | Point | Daily 1 category rotation (02:00 ICT) | /poi/nearby, /poi/by-bbox, listing sidebar, search filter |
TransportLine |
OSM `route=subway | train | highway` relations | MultiLineString |
IndustrialPark |
OSM landuse=industrial ways/relations |
Point + MultiPolygon boundary | Monthly (1st 03:00 ICT, 4 chunks) | /industrial/parks/*, KCN catalog |
OsmSyncRun |
Generated by orchestrator | — | Append-only audit | /admin/osm dashboard |
All sync writes are gated by OSM_SYNC_ENABLED=true so dev / staging
environments don't hit Overpass accidentally.
GeoLookupService — the foundation
Every other layer depends on vn_provinces.geometry for PostGIS
ST_Contains lookups. The service exposes:
const r = await geo.lookup(lng, lat);
// → { province: { code, name }, district: { code, name }, ward: { code, name } }
const inside = await geo.isInVietnam(lng, lat);
// → boolean
const cov = await geo.coverage();
// → { provinces: { total, withGeometry, lastSyncedAt }, districts: ..., wards: ... }
It replaces the old nearestProvince() heuristic that walked a
hardcoded centroid table.
Quality gates baked into sync scripts
- Geographic gate —
isPointInVietnam(lng, lat)fromscripts/data/vn-country-polygon.tsrejects rows whose centroid falls outside the VN mainland polygon (catches China / Laos / Cambodia bleed across the Overpass bbox chunks). - Name gate — rows whose
namecontains zero Latin/Vietnamese letters (/[A-Za-zÀ-ỹ]/) are dropped (filters CJK / Khmer / Thai). - Lock gate — when an admin sets
osmLocked=trueor adds a column tolockedFields, the next sync skips that row entirely (or that column) so manual edits survive.
Adding a new POI category
- Add the enum value to
PoiCategoryinprisma/schema.prismaand create a Prisma migration thatALTER TYPE "PoiCategory" ADD VALUE. - Add the Overpass selector to
CATEGORY_QUERIESinscripts/sync-osm-poi.ts. - Append the same enum value to the
POI_CATEGORIESrotation list inOsmSyncCronServiceso the cron picks it up. - Add labels + icons + colour to
apps/web/lib/poi-api.tsso the UI chips render.
That's it — OsmSyncService.findLayer('poi', 'YOUR_CAT') will return a
def automatically because SYNC_LAYERS is generated from the enum keys.
Operational runbook
- Sync hangs / 504 from Overpass —
kubectl describe podon the Kaniko-style sync runner shows the chunk in flight. The script has a 5× retry on the clone step (HTTP 504 from Gitea is transient). For Overpass itself, raise the per-script[out:json][timeout:N]by editing the script. Default 180s for POI, 300s for boundaries. - Runs stuck in
RUNNINGstate —OsmSyncOrchestratorwrites the row before spawning the script. If the script process dies without emitting anexitevent, the row stays RUNNING. Mitigation: cron job to flip RUNNING > 6h old to FAILED witherrorMessage='timeout'. - Conflict logs — when sync updates a column the admin had locked,
it skips the column silently. There is no separate conflict table
(yet). To audit, search Loki for
[osm-sync] skipping locked field.
Phase status
| Phase | Status | Notes |
|---|---|---|
| 0 — Admin boundaries + GeoLookupService | ✅ Schema, sync, service done. Provinces synced (33), districts in progress | |
| 1 — POI catalog + sync | ✅ Schema + sync script + NestJS module + sidebar component done. Hospital category synced (~500 rows) | |
| 2 — Transport (metro/railway/airport) | 🟡 Stations synced via POI; lines layer pending | |
| 3 — Buildings / landuse | ⏳ Deferred — admin says low priority | |
| 4 — Sync orchestrator + admin dashboard | ✅ Service + cron + Prometheus-friendly stats + admin UI done | |
| 5 — User-facing UX | 🟡 Listing + KCN sidebar wired; search filter widget built; map overlays pending | |
| 6 — Performance hardening | ⏳ Materialized views + Redis cache pending |