* 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>
93 lines
4.7 KiB
Markdown
93 lines
4.7 KiB
Markdown
# 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 | Monthly | Distance scoring, planned for Phase 2 UX |
|
||
| `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:
|
||
|
||
```ts
|
||
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
|
||
|
||
1. **Geographic gate** — `isPointInVietnam(lng, lat)` from
|
||
`scripts/data/vn-country-polygon.ts` rejects rows whose centroid
|
||
falls outside the VN mainland polygon (catches China / Laos /
|
||
Cambodia bleed across the Overpass bbox chunks).
|
||
2. **Name gate** — rows whose `name` contains zero Latin/Vietnamese
|
||
letters (`/[A-Za-zÀ-ỹ]/`) are dropped (filters CJK / Khmer / Thai).
|
||
3. **Lock gate** — when an admin sets `osmLocked=true` or adds a column
|
||
to `lockedFields`, the next sync skips that row entirely (or that
|
||
column) so manual edits survive.
|
||
|
||
## Adding a new POI category
|
||
|
||
1. Add the enum value to `PoiCategory` in `prisma/schema.prisma` and
|
||
create a Prisma migration that `ALTER TYPE "PoiCategory" ADD VALUE`.
|
||
2. Add the Overpass selector to `CATEGORY_QUERIES` in
|
||
`scripts/sync-osm-poi.ts`.
|
||
3. Append the same enum value to the `POI_CATEGORIES` rotation list in
|
||
`OsmSyncCronService` so the cron picks it up.
|
||
4. Add labels + icons + colour to `apps/web/lib/poi-api.ts` so 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 pod` on 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 `RUNNING` state** — `OsmSyncOrchestrator` writes the
|
||
row before spawning the script. If the script process dies without
|
||
emitting an `exit` event, the row stays RUNNING. Mitigation: cron
|
||
job to flip RUNNING > 6h old to FAILED with `errorMessage='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 |
|