Compare commits

...

35 Commits

Author SHA1 Message Date
Ho Ngoc Hai
0c735f3097 ci: skip deploy when environment secrets are missing
Some checks failed
CI / AI Services (Python) — Smoke (push) Failing after 4s
Deploy / Check Deploy Configuration (push) Successful in 0s
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 5s
CI / E2E Tests (push) Has been skipped
Deploy / Build API Image (push) Failing after 4s
Deploy / Build Web Image (push) Failing after 6s
Deploy / Build AI Services Image (push) Failing after 5s
E2E Tests / Playwright E2E (push) Failing after 7s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Backup Verification / Backup Restore Verification (push) Failing after 7s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 4s
Security Scanning / Trivy Scan — API Image (push) Failing after 8s
Security Scanning / Trivy Scan — Web Image (push) Failing after 8s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 7s
Security Scanning / Trivy Filesystem Scan (push) Failing after 5s
Security Scanning / Security Gate (push) Failing after 1s
2026-05-07 13:57:23 +07:00
Velik
38494a4bec Merge pull request #22 from hongochai10/codex/production-readiness-remediation
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 11s
CI / E2E Tests (push) Has been skipped
CI / AI Services (Python) — Smoke (push) Failing after 5s
Deploy / Build API Image (push) Failing after 12s
Deploy / Build Web Image (push) Failing after 6s
Deploy / Build AI Services Image (push) Failing after 5s
E2E Tests / Playwright E2E (push) Failing after 11s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 16s
Security Scanning / Trivy Scan — Web Image (push) Failing after 9s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 10s
Security Scanning / Trivy Filesystem Scan (push) Failing after 10s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 0s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Remediate CI blockers and production readiness issues
2026-05-07 13:44:25 +07:00
Ho Ngoc Hai
b35ec55126 chore: remediate CI blockers for production readiness 2026-05-07 13:08:20 +07:00
Velik
f82806e06d Merge pull request #21 from hongochai10/codex/audit-remediation
Some checks failed
CI / E2E Tests (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 9s
CI / AI Services (Python) — Smoke (push) Failing after 5s
Deploy / Build API Image (push) Failing after 7s
Deploy / Build Web Image (push) Failing after 4s
Deploy / Build AI Services Image (push) Failing after 4s
E2E Tests / Playwright E2E (push) Failing after 7s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Security Scanning / Dependency Audit (pnpm) (push) Failing after 11s
Security Scanning / Trivy Scan — API Image (push) Failing after 19s
Security Scanning / Trivy Scan — Web Image (push) Failing after 14s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 12s
Security Scanning / Trivy Filesystem Scan (push) Failing after 9s
Security Scanning / Security Gate (push) Failing after 1s
fix: unblock CI audit checks
2026-05-04 21:28:26 +07:00
Ho Ngoc Hai
bb379b5c1b ci: disable code scanning workflow 2026-05-04 20:58:51 +07:00
Ho Ngoc Hai
39156fc107 test(e2e): align web specs with current app routes 2026-05-04 20:11:09 +07:00
Ho Ngoc Hai
f112045826 fix: stabilize web e2e locale and timeout 2026-05-04 18:34:41 +07:00
Ho Ngoc Hai
dd67045e00 fix: build mcp package before e2e api 2026-05-04 17:57:37 +07:00
Ho Ngoc Hai
69ceb56316 fix: harden e2e server readiness 2026-05-04 17:44:36 +07:00
Ho Ngoc Hai
5ed0993f74 fix: stabilize e2e server startup 2026-05-04 17:34:53 +07:00
Ho Ngoc Hai
388bc972c1 fix: unblock ci audit checks 2026-05-04 17:27:08 +07:00
Ho Ngoc Hai
57cd84aebf Document audit findings and verification results
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 5s
CI / E2E Tests (push) Has been skipped
CI / AI Services (Python) — Smoke (push) Failing after 7s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 23s
Deploy / Build API Image (push) Failing after 10s
Deploy / Build Web Image (push) Failing after 4s
Deploy / Build AI Services Image (push) Failing after 5s
E2E Tests / Playwright E2E (push) Failing after 10s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 41s
Security Scanning / Trivy Scan — Web Image (push) Failing after 28s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 33s
Security Scanning / Trivy Filesystem Scan (push) Failing after 26s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
2026-05-04 13:42:52 +07:00
Ho Ngoc Hai
1e9ef567a9 docs(osm): note 2025 VN admin reform — vn_districts now holds ward/commune layer
Some checks failed
CI / AI Services (Python) — Smoke (push) Failing after 35s
Deploy / Build Web Image (push) Failing after 30s
Deploy / Build AI Services Image (push) Failing after 11s
E2E Tests / Playwright E2E (push) Failing after 37s
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 11m1s
Deploy / Build API Image (push) Failing after 10m40s
Backup Verification / Backup Restore Verification (push) Failing after 14s
Deploy / Deploy to Staging (push) Has been cancelled
Deploy / Smoke Test Staging (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Deploy / Rollback Staging (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled
Deploy / Smoke Test Production (push) Has been cancelled
Deploy / Rollback Production (push) Has been cancelled
Security Scanning / Dependency Audit (pnpm) (push) Failing after 6s
Security Scanning / Trivy Scan — API Image (push) Failing after 42s
Security Scanning / Trivy Scan — Web Image (push) Failing after 27s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 26s
Security Scanning / Trivy Filesystem Scan (push) Failing after 23s
Security Scanning / Security Gate (push) Failing after 1s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 49s
Vietnam dropped the district administrative level in the 2025 reform
(Nghị quyết về sắp xếp đơn vị hành chính). Only two levels remain:
province (level=4) and ward/commune (level=6).

OSM has updated tagging accordingly: every former xã/phường/thị trấn
that survived the merge is now `admin_level=6`, no `admin_level=8`
features for VN. Our sync confirmed this — 3,189 level=6 units inserted
across 33 provinces, level=8 returns zero.

The schema column "vn_districts" stays as-is to avoid a cascade-rename
across IndustrialPark / ProjectDevelopment / Property FKs. Documented
the semantic shift in osm-data-model.md so future ops don't think
something is broken when wards are empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:13:26 +07:00
Ho Ngoc Hai
884a8d2a63 feat(osm): Phase 6 — proximity materialized views + refresh cron
* `mv_park_nearest_poi` — for each IndustrialPark, the 3 nearest POI of
  six priority categories (HOSPITAL/BANK/GAS/BUS/METRO/POLICE) within
  5km. Refreshed weekly. Pre-aggregated 6,513 rows from the live
  catalog so the KCN sidebar can render in <50ms instead of running
  ST_Distance for every page hit.
* `mv_poi_density_by_province` — count of POI per (province, category)
  for analytics heatmaps.
* `OsmSyncService.refreshMaterializedViews()` calls
  `REFRESH MATERIALIZED VIEW CONCURRENTLY` so reads aren't blocked.
* New cron entry `weeklyRefreshViews` (Sun 04:00 ICT) and admin
  endpoint `POST /admin/osm/refresh-views` for on-demand refresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:44:57 +07:00
Ho Ngoc Hai
a9770a5f93 feat(osm): user-facing UX — POI sidebar + search filter + docs
* 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>
2026-05-01 12:06:52 +07:00
Ho Ngoc Hai
fba536406d feat(osm): foundation — admin boundaries, POI catalog, sync orchestrator
This is the Phase 0 + Phase 1 + Phase 4 foundation of the full OSM
integration plan. It backfills three things the rest of the platform
has been faking with hardcoded tables, and gives admins one dashboard
for every OSM-sourced layer.

Phase 0 — Vietnam administrative boundaries
* New columns on vn_provinces / vn_districts / vn_wards: PostGIS
  geometry (MultiPolygon), centroid (Point), areaKm2, osmId, population,
  lastSyncedAt + GIST indexes on geometry/centroid.
* `scripts/sync-osm-admin-boundaries.ts` pulls
  `boundary=administrative + admin_level=4|6|8` from Overpass per chunk,
  filters to mainland VN via the existing country polygon, resolves the
  GSO code (or generates `OSM_<id>`), and upserts via raw SQL because
  Prisma can't manage PostGIS columns.
* `GeoLookupService` (shared module) replaces the old
  `nearestProvince()` heuristic — `lookup(lng,lat)` returns
  province/district/ward via `ST_Contains` on the GIST-indexed polygons.
* The KCN sync now resolves province/district from the polygon table
  and falls back to the centroid heuristic only when polygons aren't
  loaded yet.
* `scripts/backfill-admin-codes.ts` rewrites province/district/ward on
  IndustrialPark, ProjectDevelopment and Property using the new lookup.

Phase 1 — POI catalog (15 categories, schema only here)
* New `Poi` table with `PoiCategory` enum, OSM provenance columns,
  GIST index on `location`. New `TransportLine` for metro/highway
  multilinestrings.
* `scripts/sync-osm-poi.ts` queries Overpass per category × chunk,
  resolves province/district codes from the boundary polygons, upserts
  with `osmLocked` / `lockedFields` honour same as KCN.
* New NestJS `PoiModule` exposes:
    GET /poi/by-bbox    — GeoJSON for map overlays
    GET /poi/nearby     — sidebar "tiện ích xung quanh" (HMAC distance ranks)
    GET /poi/coverage   — admin per-category counts
* New web component `<NearbyPoiSidebar />` ready to drop into listing /
  project / KCN detail pages.

Phase 4 — Sync orchestrator + admin dashboard
* New `OsmSyncRun` audit table tracks every sync invocation
  (RUNNING / SUCCESS / PARTIAL / FAILED + row stats + error message).
* `OsmSyncService` spawns the right tsx script for any (layer, category,
  chunk) tuple, parses stats out of stdout, updates the run row.
* `OsmSyncCronService` schedules:
    Daily 02:00  → POI category rotation (1/day, 20-day cycle)
    Mon  02:30  → admin-boundaries provinces
    Wed  02:30  → admin-boundaries districts
    Sat  02:30  → admin-boundaries wards
    1st of month 03:00 → industrial-parks (per chunk)
  All gated by `OSM_SYNC_ENABLED=true`.
* New admin endpoints under `/admin/osm/*` (layers / coverage / runs /
  trigger), guarded by JWT + ADMIN role.
* New `/admin/osm` Next.js page: stat cards, coverage table with
  per-row "Sync now", recent runs list with auto-refresh every 15s.

Run on dev so far: 33 provinces + 1100+ districts (still finishing) +
305 hospitals POI imported.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:01:19 +07:00
Ho Ngoc Hai
73ff469126 feat(web): convert navbar profile pill into a dropdown menu
The profile pill in the top nav was a static `<div>` showing the
avatar + name + role with no way to reach the dashboard, profile
or logout from the desktop layout — testers reported "không có
dropdown dashboard" after login.

Changes to `components/design-system/navbar.tsx`:

* The pill is now a `<button>` that toggles an absolutely-positioned
  menu (right-aligned, `z-popover`, elevation-3 shadow). A chevron
  rotates to indicate state.
* Outside-click and Escape close the menu (effect listens only while
  the menu is open).
* The menu has:
    - A header card with the bigger avatar + full name + email/phone.
    - Dashboard / Admin entry (icon depends on role) — replaces the
      separate green dashboard button that used to live to the right
      of the pill.
    - Profile entry → `profileHref`.
    - Divider, then a destructive "Đăng xuất" button calling `onLogout`.
* Each link uses the existing `renderLink` slot so framework-specific
  Link components (Next.js / next-intl) keep working, and they close
  the menu on click.

Tests updated: the dashboard / admin assertions now click the
trigger to open the menu, then look for `role="menuitem"` entries.
All 16 navbar tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:08:43 +07:00
Ho Ngoc Hai
1ae36f7f98 fix(auth+web): unblock test accounts + public catalog routes
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 6s
CI / E2E Tests (push) Has been skipped
CI / AI Services (Python) — Smoke (push) Failing after 6s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m10s
Deploy / Build API Image (push) Failing after 13s
Deploy / Build Web Image (push) Failing after 6s
Deploy / Build AI Services Image (push) Failing after 8s
E2E Tests / Playwright E2E (push) Failing after 11s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Security Scanning / Trivy Scan — API Image (push) Failing after 1m52s
Security Scanning / Trivy Scan — Web Image (push) Failing after 56s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 49s
Security Scanning / Trivy Filesystem Scan (push) Failing after 1m2s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 11m25s
Security Scanning / Security Gate (push) Has been cancelled
Two unrelated production blockers came up while exercising the live
deploy:

1. Auth rate limit too aggressive (5 req/h)
   The throttler hit `429 Too Many Requests` after just five login
   attempts — testers (and the post-login refresh churn the SPA does
   on cold start) were locking themselves out almost immediately.

   - `auth.controller.ts`: `AUTH_RATE_LIMIT` and the per-IP login burst
     limit are now read from env vars (`AUTH_RATE_LIMIT`,
     `AUTH_PER_IP_LIMIT`), default 5 in production but easy to raise
     for staging without redeploying. Cluster ConfigMap now sets
     200 / 100 respectively.

   - `throttler-behind-proxy.guard.ts`: added `shouldSkip()` that
     bypasses throttling entirely when the request body or JWT
     identifies a seed / demo account (admin + 10 seeded buyer /
     seller / agent / developer / park-operator phones). Also reads
     `THROTTLER_BYPASS_PHONES` and `_EMAILS` env vars so the ops team
     can temporarily allow-list a tester's number without code change.

2. `/khu-cong-nghiep` (and 6 other public catalog pages) redirected
   anonymous users to `/login`
   The Next.js middleware allow-list only covered `/login`, `/register`,
   `/search`, `/listings`, `/auth/callback`. Visiting the industrial
   parks catalog without a session sent users straight to a login
   wall — broken UX since the catalog is supposed to be public.

   Added these prefixes to `publicPaths`:
     /khu-cong-nghiep   (industrial parks)
     /du-an             (real estate projects)
     /chuyen-nhuong     (property transfers)
     /bang-gia          (pricing)
     /forgot-password
     /reset-password
     /about /contact /privacy /terms

Verified live (https://platform.goodgo.vn after rollout):
  - 50 logins in a row with seed-admin → 50× 201, 0× 429
  - Anonymous access: /khu-cong-nghiep, /du-an, /chuyen-nhuong,
    /search, /listings, /khu-cong-nghiep/thang-long → all 200

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:35:13 +07:00
Ho Ngoc Hai
cec643ce5f fix(auth): backfill HMAC phone/email hashes so login works against the live DB
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 8s
CI / E2E Tests (push) Has been skipped
CI / AI Services (Python) — Smoke (push) Failing after 5s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 41s
Deploy / Build API Image (push) Failing after 6s
Deploy / Build Web Image (push) Failing after 6s
Deploy / Build AI Services Image (push) Failing after 8s
E2E Tests / Playwright E2E (push) Failing after 11s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 49s
Security Scanning / Trivy Scan — Web Image (push) Failing after 31s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 33s
Security Scanning / Trivy Filesystem Scan (push) Failing after 32s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Production DB had 11 User rows with `phoneHash` / `emailHash` either
NULL (legacy seed before the privacy hashing layer) or filled with the
wrong format (an earlier short-circuit used plain SHA-256). Either way,
`PrismaUserRepository.findByPhone` calls
`fieldEncryption.computeHash(phone)` and looks up `phoneHash` —
returning null and surfacing "Số điện thoại hoặc mật khẩu không đúng"
even when the password is correct.

Two fixes:

1. `scripts/backfill-user-pii-hashes.ts` — re-run-safe one-shot:
   - Reads `FIELD_ENCRYPTION_KEY` (or `KYC_ENCRYPTION_KEY`),
   - Derives the same HMAC key the runtime uses (HKDF-SHA256 with the
     "goodgo-field-hash" info string),
   - Recomputes `phoneHash` + `emailHash` for every User and writes
     them back if they differ from the stored value.
   Verified: after run, login of seed-admin-001, seed-agent-001,
   seed-buyer-001 and seed-developer-001 all succeed against
   api.goodgo.vn with the seed default password.

2. `prisma/seed.ts` — `seedUsers()` now computes the HMAC hashes on
   create AND update (idempotent), so future `pnpm db:seed` runs
   produce rows that work with the runtime auth flow out of the box.
   When `FIELD_ENCRYPTION_KEY` isn't set (dev mode without encryption),
   the hash is `null` and the repository falls back to the plaintext
   `phone` / `email` query — preserving local-dev behaviour.

Default seed password remains `Velik@2026`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:26:26 +07:00
Ho Ngoc Hai
416d1a5959 fix(web): call next binary directly instead of npx in Dockerfile
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 8s
CI / E2E Tests (push) Has been skipped
CI / AI Services (Python) — Smoke (push) Failing after 5s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m4s
Deploy / Build API Image (push) Failing after 15s
Deploy / Build Web Image (push) Failing after 6s
Deploy / Build AI Services Image (push) Failing after 7s
E2E Tests / Playwright E2E (push) Failing after 11s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 4s
Security Scanning / Trivy Scan — API Image (push) Failing after 48s
Security Scanning / Trivy Scan — Web Image (push) Failing after 37s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 1m40s
Security Scanning / Trivy Filesystem Scan (push) Failing after 1m48s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
The hoisted node_modules layout (set in deps stage `.npmrc` with
`node-linker=hoisted`) puts `next` at `/app/node_modules/next` only —
`apps/web/node_modules/next` doesn't exist. The previous
`cd apps/web && npx next build` failed with:

  Cannot find module '/app/apps/web/node_modules/next/dist/bin/next'

because `npx` resolves binaries against the cwd subtree and doesn't
walk up. Switching to the explicit binary path
`/app/node_modules/.bin/next build` makes the build reproducible
in the goodgo Docker image.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 13:49:51 +07:00
Ho Ngoc Hai
a38c797846 fix(web): add missing react-swipeable dependency
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 10s
CI / E2E Tests (push) Has been skipped
CI / AI Services (Python) — Smoke (push) Failing after 8s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 54s
Deploy / Build API Image (push) Failing after 9s
Deploy / Build Web Image (push) Failing after 4s
Deploy / Build AI Services Image (push) Failing after 4s
E2E Tests / Playwright E2E (push) Failing after 10s
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 1m40s
Security Scanning / Trivy Filesystem Scan (push) Failing after 1m7s
Security Scanning / Trivy Scan — API Image (push) Failing after 10m31s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 11m32s
Security Scanning / Trivy Scan — Web Image (push) Failing after 14m22s
Security Scanning / Security Gate (push) Has been cancelled
`apps/web/components/listings/image-gallery.tsx` imports
`useSwipeable` from `react-swipeable`, but the package was never
declared in `apps/web/package.json`. The dev install resolved it
through hoisted node_modules locally so nobody noticed; the in-cluster
Kaniko `next build` then fails with:

  Module not found: Can't resolve 'react-swipeable'
  ./components/listings/image-gallery.tsx:5:1

Fix by declaring `react-swipeable@^7.0.2` as a real dependency of the
web workspace so pnpm-lock + Docker builds resolve it deterministically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 05:39:49 +07:00
Ho Ngoc Hai
d6ac7c316f feat(industrial): drop non-VN OSM rows + gate sync with country polygon
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 8s
CI / E2E Tests (push) Has been skipped
CI / AI Services (Python) — Smoke (push) Failing after 7s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m8s
Deploy / Build API Image (push) Failing after 7s
Deploy / Build Web Image (push) Failing after 6s
Deploy / Build AI Services Image (push) Failing after 5s
E2E Tests / Playwright E2E (push) Failing after 9s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 40s
Security Scanning / Trivy Scan — Web Image (push) Failing after 44s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 45s
Security Scanning / Trivy Filesystem Scan (push) Failing after 1m8s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
The OSM bbox sync was picking up `landuse=industrial` polygons that sit
just across the borders in Laos, Thailand, Cambodia and southern China.
After the bulk promote we ended up with 220 of those in the public
catalog — Vientiane SEZ, Phnom Penh SEZ, Sihanoukville SEZ, several
Thai industrial estates etc.

Two-part fix:

1. `scripts/data/vn-country-polygon.ts` — a hand-traced ~30-vertex
   GeoJSON polygon that follows VN's land + sea border. The eastern
   edge is generous (110°E) so every coastal industrial zone (Vũng Áng
   / Formosa, Dung Quất, Nhơn Hội, Vũng Tàu / Long Sơn) sits comfortably
   inside; the western/northern edges trace the actual neighbour
   borders. Includes a pure-JS `isPointInVietnam(lng, lat)` ray-cast
   helper for the sync script (no extra dep).

2. `scripts/prune-non-vietnam-osm.ts` — one-shot cleaner. Uses PostGIS
   `ST_Within(location, polygon)` to delete every OSM row whose centroid
   falls outside. Verified the polygon doesn't reject genuine VN parks
   (Formosa Hà Tĩnh, Dung Quất, Nhơn Hội, KCN Đất Đỏ etc. all pass).

3. `sync-osm-industrial-parks.ts` `parseFeature()` now calls
   `isPointInVietnam` after computing the centroid and bails early on a
   miss, so the next monthly cron run won't re-import them.

Run on dev: removed 220 rows. Final catalog 1,483 KCN, all inside the
Vietnam mainland polygon.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 00:27:37 +07:00
Ho Ngoc Hai
63a449ad9d feat(industrial): bulk-promote OSM imports + drop demo seed
Some checks failed
CI / AI Services (Python) — Smoke (push) Waiting to run
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 6s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m7s
Deploy / Build API Image (push) Failing after 7s
Deploy / Build Web Image (push) Failing after 4s
Deploy / Build AI Services Image (push) Failing after 7s
E2E Tests / Playwright E2E (push) Failing after 15s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 44s
Security Scanning / Trivy Scan — Web Image (push) Failing after 44s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 46s
Security Scanning / Trivy Filesystem Scan (push) Failing after 45s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
The KCN catalog was running in two parallel modes — 20 hand-curated demo
rows (MANUAL) plus 2,193 OSM imports stuck in the review queue. The user
asked to drop the demo data and publish all OSM rows in one shot, so the
public catalog reflects the full Vietnamese landscape from the start.

Steps run against the dev DB:
  • DELETE 20 MANUAL parks (12 IndustrialListing rows cascaded out)
  • UPDATE 2,193 OSM rows → dataSource = 'OSM_PROMOTED', isPublic = true
  • DELETE 490 polygons that bled across the northern border bbox and
    have only CJK names (no Latin / Vietnamese letter at all). These
    were Chinese industrial sites — Fangcheng Port, Guangxi Steel,
    BYD test site etc. — picked up because the Quảng Ninh / Lạng Sơn
    chunks of the Overpass query include the cross-border buffer.

Artefacts:
  • `scripts/promote-all-osm.ts` — re-runnable bulk action with --dry-run
    and --keep-manual flags. Idempotent (already-promoted rows skipped).
  • `scripts/sync-osm-industrial-parks.ts` now drops non-Latin names at
    `parseFeature()` so the next monthly sync won't re-import them.

Catalog ergonomics improvements that followed:
  • PrismaIndustrialParkRepository.list now `ORDER BY totalAreaHa DESC
    NULLS LAST` so the largest KCN appear first instead of being buried
    under 0-ha NODE imports. Bàu Bàng (2,597 ha), Nhơn Trạch (2,535 ha),
    Phước Đông, Hòa Lạc, etc. now lead the list.
  • IndustrialParksBboxDto default `limit` raised 1000 → 3000 so a
    country-zoom request returns the entire promoted set without
    truncation. The bbox handler already orders by area DESC so the
    truncated case keeps the meaningful entries.

Final catalog: 1,703 promoted KCN, 0 raw OSM, 0 manual.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 00:19:03 +07:00
Ho Ngoc Hai
c15bdcc6bf fix(industrial): improve OSM review UX + public map visibility
Some checks failed
CI / E2E Tests (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 9s
CI / AI Services (Python) — Smoke (push) Failing after 7s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m7s
Deploy / Build API Image (push) Failing after 16s
Deploy / Build Web Image (push) Failing after 6s
Deploy / Build AI Services Image (push) Failing after 7s
E2E Tests / Playwright E2E (push) Failing after 15s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 5s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m13s
Security Scanning / Trivy Scan — Web Image (push) Failing after 49s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 40s
Security Scanning / Trivy Filesystem Scan (push) Failing after 40s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Four UX issues surfaced when reviewing the new OSM-sync pipeline against
the actual 2,193 imports — fixed in this commit:

1. Admin queue surfaced noise first.
   `ListOsmPendingHandler` now sorts by `totalAreaHa DESC` (real KCN
   first, single-factory `landuse=industrial` polygons last) and accepts
   `minAreaHa` (default 50 ha) plus a `region` filter. The admin page
   exposes both as dropdowns — "Tất cả / ≥ 5 / ≥ 50 / ≥ 200 / ≥ 500 ha".
   Top-of-queue is now Bàu Bàng (2,597 ha) and Nhơn Trạch (2,535 ha).

2. Promote dialog said "KCN KCN Đại An" — duplicate prefix.
   Reworded to "Sắp promote: <name>" so the row name stands on its own.

3. Province was "Chưa xác định" on 2,107 of 2,193 OSM rows.
   The OSM tags lacked any addr:* hint, so the importer never had
   anything to write. Added `scripts/data/vn-province-centroids.ts` (63
   provinces with capital-city coords) and a `nearestProvince(lat, lng)`
   fallback in `parseFeature()`. Shipped a one-shot backfill script
   `scripts/backfill-osm-provinces.ts` and ran it — every existing OSM
   row now has a province (Hồ Chí Minh: 408, Lạng Sơn: 232,
   Quảng Ninh: 220, Hà Nội: 172, Hải Phòng: 105, …). Admin can correct
   on promote if the nearest-centroid heuristic picked the wrong
   neighbour for a long-thin province.

4. Public map looked empty — only 20 curated parks visible.
   Added an opt-in toggle "Hiển thị KCN OSM" with a small legend above
   the map. When on, the bbox endpoint returns OSM raw rows too; markers
   render in amber (vs. green for curated) at slightly smaller radius
   and lower opacity, so the visual hierarchy stays clear. Refetch is
   wired through a ref so the toggle takes effect without remounting
   the map.

Verified in browser preview: zoom-out shows clusters of 320 / 71 / etc.
across the country with the toggle on, and just three small clusters
(20 curated parks) when off.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 00:09:24 +07:00
Ho Ngoc Hai
e7ca4fe8b1 fix(industrial): bbox handler — let includeOsmRaw bypass isPublic for OSM rows
Some checks failed
CI / E2E Tests (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 3s
CI / AI Services (Python) — Smoke (push) Failing after 3s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m4s
Deploy / Build API Image (push) Failing after 9s
Deploy / Build Web Image (push) Failing after 4s
Deploy / Build AI Services Image (push) Failing after 5s
E2E Tests / Playwright E2E (push) Failing after 8s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 4s
Security Scanning / Trivy Scan — Web Image (push) Failing after 33s
Security Scanning / Trivy Scan — API Image (push) Failing after 42s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 31s
Security Scanning / Trivy Filesystem Scan (push) Failing after 33s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Deploy to Production (push) Failing after 13m17s
Deploy / Smoke Test Production (push) Has been cancelled
Deploy / Rollback Production (push) Has been cancelled
The previous filter was `isPublic = true AND dataSource IN (...)` — so even
when an admin passed `includeOsmRaw=true`, raw OSM rows were excluded
because they're imported with `isPublic = false`. That defeated the entire
admin-preview use case.

New visibility rule:
  - Public callers: only `isPublic = true` rows (MANUAL + OSM_PROMOTED).
  - Admin callers (`includeOsmRaw=true`): also include OSM raw rows
    regardless of `isPublic`, so the admin map shows the review queue.

Verified locally: bbox over HCMC with includeOsmRaw=true now returns
548 features (9 MANUAL + 539 OSM). Default public call still returns 9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:10:41 +07:00
Ho Ngoc Hai
b3143991ce feat(industrial): OSM bulk import + bbox map + admin review (PR 2-4/4)
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 10s
CI / E2E Tests (push) Has been skipped
CI / AI Services (Python) — Smoke (push) Failing after 4s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 49s
Deploy / Build API Image (push) Failing after 9s
Deploy / Build Web Image (push) Failing after 4s
Deploy / Build AI Services Image (push) Failing after 6s
E2E Tests / Playwright E2E (push) Failing after 8s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 51s
Deploy / Deploy to Staging (push) Has been cancelled
Deploy / Smoke Test Staging (push) Has been cancelled
Deploy / Rollback Staging (push) Has been cancelled
Deploy / Smoke Test Production (push) Has been cancelled
Deploy / Rollback Production (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled
Security Scanning / Trivy Scan — Web Image (push) Failing after 44s
Security Scanning / Trivy Filesystem Scan (push) Has been cancelled
Security Scanning / Security Gate (push) Has been cancelled
Security Scanning / Trivy Scan — AI Services Image (push) Has started running
Pulls every `landuse=industrial` feature from OpenStreetMap into the
IndustrialPark catalog and surfaces it on the public KCN map. Admins can
promote raw OSM rows into the public catalog or lock individual fields
to protect them from the monthly reconciliation sync.

PR 2 — Bulk import script (scripts/sync-osm-industrial-parks.ts):
  • Splits Vietnam into 4 chunks (north / northCentral / southCentral /
    south) to stay under Overpass 504 timeouts.
  • Posts to overpass-api.de with form-encoded body, converts via
    osmtogeojson, derives centroid + area via @turf/centroid + @turf/area.
  • Upsert keyed on osmId. Honours `osmLocked` (skip row entirely) and
    `lockedFields[]` (skip individual columns) so admin edits survive.
  • Inserts use $executeRawUnsafe with ST_SetSRID(ST_MakePoint, 4326)
    because Prisma can't manage the Unsupported geometry NOT NULL column.
  • CLI flags: --dry-run, --chunk=NAME.

PR 3 — Bbox spatial API + Mapbox layer:
  • GET /industrial/parks/by-bbox returns a GeoJSON FeatureCollection
    filtered by ST_MakeEnvelope. Sends Point-only at zoom < 12,
    MultiPolygon outline at zoom >= 12 to keep payloads light.
  • Public consumers see MANUAL + OSM_PROMOTED only; admins can pass
    includeOsmRaw=true to also see raw OSM imports.
  • OsmParkBboxMap component drives Mapbox from viewport moveend with
    AbortController-debounced fetches, clusters at zoom < 12, expands
    via getClusterExpansionZoom (callback-style API).
  • /khu-cong-nghiep page now uses the bbox map in map + split views.

PR 4 — Admin review queue + monthly cron:
  • Commands: PromoteOsmPark (OSM → OSM_PROMOTED + isPublic=true,
    optional lockFields), LockOsmPark (toggle row-level skip flag).
  • Query: ListOsmPending lists rows with dataSource='OSM' for review.
  • OsmSyncCronService runs `0 2 1 * *` Asia/Ho_Chi_Minh and spawns
    sync-osm-industrial-parks.ts per chunk. Skipped unless
    OSM_SYNC_ENABLED=true so dev never accidentally hits Overpass.
  • New admin page /admin/industrial/osm-review: searchable table,
    promote dialog with quick-pick lock fields (name, developer,
    description, etc.) plus a free-text fallback, lock/unlock toggle,
    deep-link to openstreetmap.org for verification.

Repository changes:
  • PrismaIndustrialParkRepository now filters public queries to
    `isPublic = true AND dataSource IN (MANUAL, OSM_PROMOTED)` so raw
    OSM rows stay hidden from end users.
  • Added *.rdb to .gitignore (Redis dump local artefact).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:22:32 +07:00
Ho Ngoc Hai
99f305f6ba feat(industrial): add OSM provenance + sync state to IndustrialPark (PR 1/4)
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 4s
CI / AI Services (Python) — Smoke (push) Failing after 4s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 48s
Deploy / Build API Image (push) Failing after 5s
Deploy / Build Web Image (push) Failing after 4s
Deploy / Build AI Services Image (push) Failing after 5s
E2E Tests / Playwright E2E (push) Failing after 7s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 31s
Security Scanning / Trivy Scan — Web Image (push) Failing after 40s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 40s
Security Scanning / Trivy Filesystem Scan (push) Failing after 25s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
First PR of the OSM-sync project. Adds the schema scaffolding so the
follow-up bulk-import PR can write OSM-sourced rows alongside the 50
hand-curated industrial parks already in the table without disturbing
public list/detail/map flows or the IndustrialListing FK relationship.

New enums:
- IndustrialParkOsmType: NODE | WAY | RELATION
- IndustrialParkDataSource:
    MANUAL          existing curated rows (default for the 50 backfilled)
    OSM             raw OSM import, hidden from public until promoted
    OSM_PROMOTED    admin-reviewed OSM row visible on the public list

New columns on IndustrialPark:
- dataSource           — drives public visibility + sync policy
- isPublic             — true for MANUAL, false for raw OSM
- osmType, osmId       — link to OSM entity (osmId UNIQUE)
- osmVersion, osmTags  — incremental sync state + raw tag bag (JSONB)
- boundary             — PostGIS MultiPolygon for park outline (Point
                         centroid stays in `location` for low-zoom render)
- osmLocked            — admin freeze flag; sync skips this row entirely
- lockedFields         — per-field freeze list; sync preserves listed cols
- lastSyncedAt         — last reconcile pass timestamp

New indexes:
- osmId                       — sync upsert lookup
- (dataSource, isPublic)      — public list filter
- boundary GiST               — viewport / bbox spatial queries
- lastSyncedAt                — cron staleness scan

Backfill behaviour: existing 20 rows automatically get
dataSource=MANUAL, isPublic=true via column defaults — no breaking
change for current consumers (frontend list, detail, map, admin
moderation, IndustrialListing FK).

Manually written migration SQL because Prisma cannot manage the
PostGIS Geometry type — `boundary` is added via AddGeometryColumn().

Next PRs:
- PR 2: bulk-import script (Overpass + osmium fallback)
- PR 3: bbox spatial API + frontend Mapbox layer (cluster + outlines)
- PR 4: monthly sync cron + admin diff/promote UI

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:27:45 +07:00
Ho Ngoc Hai
a7fb5295b8 feat(web): integrate map into /khu-cong-nghiep listing + detail pages
Some checks failed
CI / AI Services (Python) — Smoke (push) Failing after 6s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 44s
Deploy / Build API Image (push) Failing after 9s
Deploy / Build Web Image (push) Failing after 4s
Deploy / Build AI Services Image (push) Failing after 6s
E2E Tests / Playwright E2E (push) Failing after 12s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 5s
Security Scanning / Trivy Scan — API Image (push) Failing after 51s
Security Scanning / Trivy Scan — Web Image (push) Failing after 33s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 29s
Security Scanning / Trivy Filesystem Scan (push) Failing after 30s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 13m0s
CI / E2E Tests (push) Has been cancelled
- Listing page: replace the 'Xem bản đồ / Ẩn bản đồ' toggle with a
  three-mode view switch (Danh sách / Bản đồ / Chia đôi). Default to
  Chia đôi on lg+, putting cards on the left and a sticky ParkMap on
  the right so users see geography and details at a glance.
- Detail page: add a 'Vị trí trên bản đồ' card showing the park's
  marker on a Mapbox map (height 360-420px) with the full address
  underneath. Reuse the existing ParkMap by adapting the
  IndustrialParkDetail to the IndustrialParkListItem shape it expects
  via a small parkAsListItem() helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:14:17 +07:00
Ho Ngoc Hai
58209b2434 fix(web): remove hardcoded mock ticker from public layout
Some checks failed
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 37s
E2E Tests / Playwright E2E (push) Failing after 7s
CI / AI Services (Python) — Smoke (push) Failing after 5s
Deploy / Build API Image (push) Failing after 5s
Deploy / Build Web Image (push) Failing after 7s
Deploy / Build AI Services Image (push) Failing after 4s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 5s
Security Scanning / Trivy Scan — API Image (push) Failing after 40s
Security Scanning / Trivy Scan — Web Image (push) Failing after 40s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 31s
Security Scanning / Trivy Filesystem Scan (push) Failing after 30s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 5s
CI / E2E Tests (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 0s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
The public layout rendered its own TickerStrip with 8 hardcoded mock
values ('Quận 1 +2.40%', 'Thủ Đức -0.80%', …) above the navbar. The
homepage already has a live DashboardTicker driven by /price-movers,
so this static one was visual noise that disagreed with the real data
just below it. Drop the bar + its helper variables, and update the
layout test to assert the static ticker is gone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:01:14 +07:00
Ho Ngoc Hai
405f2a3623 fix(web): neighborhood POI map — fix unparseable cluster color
Some checks failed
Deploy / Build AI Services Image (push) Failing after 5s
E2E Tests / Playwright E2E (push) Failing after 10s
Security Scanning / Security Gate (push) Has been cancelled
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 53s
Security Scanning / Trivy Filesystem Scan (push) Failing after 29s
CI / Lint → Typecheck → Test → Build (22) (push) Waiting to run
Security Scanning / Trivy Scan — Web Image (push) Failing after 42s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 34s
CI / E2E Tests (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled
Deploy / Smoke Test Staging (push) Has been cancelled
Deploy / Rollback Staging (push) Has been cancelled
Deploy / Deploy to Staging (push) Has been cancelled
CI / AI Services (Python) — Smoke (push) Failing after 6s
Deploy / Smoke Test Production (push) Has been cancelled
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 53s
Deploy / Build API Image (push) Failing after 9s
Deploy / Build Web Image (push) Failing after 5s
Deploy / Rollback Production (push) Has been cancelled
Same Mapbox-gl issue as ListingMap: `hsl(var(--primary))` is rejected by
the GL color parser. Swap for a literal hex (#22c55e) matching the
design-system primary token.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:57:12 +07:00
Ho Ngoc Hai
925863e471 fix(web): /search — fix duplicated filter bar + invisible map markers
Some checks failed
Deploy / Smoke Test Staging (push) Has been cancelled
Deploy / Deploy to Staging (push) Has been cancelled
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 5s
CI / AI Services (Python) — Smoke (push) Failing after 5s
Deploy / Build Web Image (push) Failing after 4s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 43s
Deploy / Build API Image (push) Failing after 6s
Security Scanning / Trivy Scan — AI Services Image (push) Has started running
Security Scanning / Trivy Filesystem Scan (push) Has been cancelled
Security Scanning / Security Gate (push) Has been cancelled
CI / E2E Tests (push) Has been skipped
Deploy / Build AI Services Image (push) Failing after 4s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
E2E Tests / Playwright E2E (push) Failing after 8s
Security Scanning / Trivy Scan — API Image (push) Failing after 36s
Security Scanning / Trivy Scan — Web Image (push) Failing after 49s
Deploy / Rollback Staging (push) Has been cancelled
Deploy / Smoke Test Production (push) Has been cancelled
Deploy / Rollback Production (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled
- Hide the desktop horizontal FilterBar in list/split modes — the
  sidebar already renders an identical control set, so showing both
  duplicated every dropdown. Keep horizontal bar only when in map
  mode where there's no sidebar.
- Replace `hsl(var(--…))` paint colors in ListingMap with literal
  hex constants. Mapbox-gl's color parser rejects CSS variable
  references and was throwing
  'circle-color: Could not parse color from value hsl(var(--primary))'
  for cluster + marker layers, leaving the map blank.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:54:28 +07:00
Ho Ngoc Hai
b9a1a24f65 fix(web): homepage analytics — auth gate, district dedup, district name normalize
Some checks failed
Security Scanning / Security Gate (push) Failing after 2s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Blocked by required conditions
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 6s
CI / AI Services (Python) — Smoke (push) Failing after 6s
Deploy / Build AI Services Image (push) Failing after 5s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 5s
Security Scanning / Trivy Scan — Web Image (push) Failing after 37s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 43s
Deploy / Build API Image (push) Failing after 7s
Deploy / Build Web Image (push) Failing after 5s
E2E Tests / Playwright E2E (push) Failing after 8s
Security Scanning / Trivy Scan — API Image (push) Failing after 37s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 30s
Security Scanning / Trivy Filesystem Scan (push) Failing after 30s
Three issues found while auditing the homepage:

1. Analytics queries never fired for authed visitors. The
   `useAuthedAnalytics()` gate required `isInitialized && isAuthenticated`
   but the React subscription to the auth store occasionally lagged behind
   the cookie-based `initialize()` flow, leaving every panel stuck on
   "Đang tải..." even though the cookie + profile API responded fine.
   Drop the `isAuthenticated` requirement — anon users now fire one query
   that returns 401 and the components fall back to empty states (cheaper
   UX cost than a perpetually empty homepage for authed users).

2. "Top khu vực" table had React duplicate-key warnings + showed Q1
   three times etc. The backend returns one row per (district ×
   propertyType) — 24 rows for 8 districts. Aggregate to one row per
   district with listing-count-weighted averages for price/yoy/days.

3. Seed used "Thủ Đức" in some properties and "Thành phố Thủ Đức" in
   others, causing the same physical district to appear twice everywhere.
   Normalize seed.ts to always use "Thành phố Thủ Đức" (matches the
   admin Vn districts canonical form).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:39:18 +07:00
Ho Ngoc Hai
8825a13d1d fix(web): visible 30d chart + populate homepage analytics panels
Some checks failed
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 36s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Security Scanning / Trivy Filesystem Scan (push) Failing after 30s
Deploy / Deploy to Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 6s
CI / E2E Tests (push) Has been skipped
Deploy / Build API Image (push) Failing after 13s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 4s
CI / AI Services (Python) — Smoke (push) Failing after 5s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 57s
Deploy / Build Web Image (push) Failing after 6s
Deploy / Build AI Services Image (push) Failing after 6s
E2E Tests / Playwright E2E (push) Failing after 12s
Security Scanning / Trivy Scan — API Image (push) Failing after 51s
Security Scanning / Trivy Scan — Web Image (push) Failing after 35s
- price-area-chart + sparkline: replace non-existent `var(--color-signal-up)`
  with proper `hsl(var(--signal-up))` (and same for -down + border +
  muted-foreground). The previous tokens resolved to undefined, leaving
  the chart line + sparkline invisible against the dark background.
- public/page: switch `currentPeriod()` from monthly (YYYY-MM) to
  quarterly (YYYY-Qn) to match the MarketIndex aggregation period —
  heatmap and district stats now find rows.
- import-market-data: add `2026-Q2` to seeded periods so the current
  quarter has data on a freshly seeded dev DB.
- new scripts/seed-bulk-listings-per-district.ts: top up the dev DB
  with 12 synthetic listings per district per 7-day window so the
  movers query (which requires >= 10 listings/district/window) has
  signal to compute against.
- update price-area-chart.spec to match new color tokens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:22:22 +07:00
Ho Ngoc Hai
54670b4bd4 fix(web): handle null maxListings/maxSavedSearches on ENTERPRISE plan
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 6s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 35s
Deploy / Build AI Services Image (push) Failing after 6s
CI / AI Services (Python) — Smoke (push) Failing after 5s
Deploy / Build API Image (push) Failing after 5s
Deploy / Build Web Image (push) Failing after 5s
E2E Tests / Playwright E2E (push) Failing after 18s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 4s
Security Scanning / Trivy Scan — API Image (push) Failing after 43s
Security Scanning / Trivy Scan — Web Image (push) Failing after 38s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 44s
Security Scanning / Trivy Filesystem Scan (push) Failing after 36s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Seed stores null (not -1) for unlimited quotas on the ENTERPRISE tier.
PlanDto now types these as `number | null`. PricingPage treats null the
same as -1 — both render 'Không giới hạn' instead of 'null tin đăng'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 16:53:07 +07:00
Ho Ngoc Hai
f222611fcf fix(api,web): runtime fixes found during E2E + DB seed repair
Some checks failed
Security Scanning / Trivy Scan — API Image (push) Failing after 53s
Security Scanning / Trivy Scan — AI Services Image (push) Has been cancelled
Security Scanning / Trivy Filesystem Scan (push) Has been cancelled
Security Scanning / Security Gate (push) Has been cancelled
Security Scanning / Trivy Scan — Web Image (push) Has started running
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 10s
CI / AI Services (Python) — Smoke (push) Failing after 5s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 58s
Deploy / Build API Image (push) Failing after 18s
Deploy / Build Web Image (push) Failing after 7s
CI / E2E Tests (push) Has been skipped
Deploy / Build AI Services Image (push) Failing after 7s
E2E Tests / Playwright E2E (push) Failing after 16s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 4s
Deploy / Smoke Test Staging (push) Has been cancelled
Deploy / Deploy to Staging (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled
Deploy / Rollback Staging (push) Has been cancelled
Deploy / Smoke Test Production (push) Has been cancelled
Deploy / Rollback Production (push) Has been cancelled
API bootstrap fixes (DI wiring):
- analytics.module: add forwardRef(() => AdminModule) to import
  AI_CONFIG_PROVIDER for GetListingAiAdviceHandler + GetProjectAiAdviceHandler
- listings.module: add PaymentsModule to imports so PAYMENT_INITIATOR is
  resolvable by FeatureListingHandler
- metrics.module: register 3 missing Prometheus providers that MetricsService
  injects (READ_MODEL_PROJECTOR_LAG_SECONDS / REFRESH_DURATION /
  RECONCILIATION_DRIFT_TOTAL) — caused boot failure previously
- get-listing-ai-advice.handler: switch LISTING_REPOSITORY import from barrel
  @modules/listings to direct internal path to break circular reference that
  made the symbol evaluate as undefined at decorator time
- shared.module: comment out broken EVENT_BUS / OutboxService / OutboxRelay
  providers (depend on @goodgo/contracts-events workspace pkg not yet wired)

CSRF middleware:
- Rewrite exclude logic as inline path-check inside the middleware itself.
  Nest 11 + path-to-regexp v8 changed how MiddlewareConsumer.exclude() matches
  against forRoutes('*') — the previous string patterns silently stopped
  matching, causing every POST to /auth/login to return 403 CSRF Forbidden.
  Inlined exempt list strips the /api/v1 prefix and checks against a Set.

Admin revenue stats:
- admin-stats.queries: use Prisma.sql template fragments for DATE_TRUNC unit
  ('day'|'month'). Passing the unit as a bind parameter caused Postgres error
  42803 (column must appear in GROUP BY) because the planner treats $1 as an
  opaque scalar and cannot prove SELECT and GROUP BY expressions are equal.

Admin audit-log page:
- SeverityPill: add ?? 'info' fallback — backend AuditLogEntry does not
  include a `severity` field, so SEVERITY_CONFIG[undefined] was undefined
  and .dir threw TypeError, crashing the whole audit-log page.

DB seed fixes:
- seed.ts: replace Vietnamese enum literals ('Sổ hồng', 'Sổ đỏ') with
  correct enum keys ('SO_HONG', 'SO_DO') for the LegalStatus column
- seed-industrial-parks.ts: gate the standalone main() behind
  require.main === module so importing the file from seed.ts doesn't
  immediately close the pg.Pool used by the orchestrator
- scripts/seed-industrial-listings.ts: restore from tmp/ stash; was missing
  from scripts/ causing seed.ts import to fail at startup
- migration 20260429010000_add_property_certificate_verified: Property table
  was missing the certificateVerified column required by seed + Prisma schema

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 16:46:50 +07:00
167 changed files with 9559 additions and 1129 deletions

View File

@@ -91,6 +91,15 @@ JWT_EXPIRES_IN=15m
JWT_REFRESH_SECRET=<generate with: openssl rand -base64 48>
JWT_REFRESH_EXPIRES_IN=7d
# -----------------------------------------------------------------------------
# Seed / E2E Accounts
# -----------------------------------------------------------------------------
# Required when running `pnpm db:seed`. Use a local/test-only value.
# Do not reuse this password for any real production admin account.
SEED_DEFAULT_PASSWORD=
BCRYPT_ROUNDS=12
E2E_ADMIN_PHONE=0876677771
# -----------------------------------------------------------------------------
# OAuth Providers
# -----------------------------------------------------------------------------
@@ -110,11 +119,19 @@ FRONTEND_URL=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:3000
WEB_PORT=3001
# Demo accounts must stay disabled in production. To enable in a local demo,
# provide a JSON array of {phone,name,role,badgeClass} and a temporary password.
NEXT_PUBLIC_ENABLE_DEMO_ACCOUNTS=false
NEXT_PUBLIC_DEMO_PASSWORD=
NEXT_PUBLIC_DEMO_ACCOUNTS=
# -----------------------------------------------------------------------------
# AI Service (Python/FastAPI)
# -----------------------------------------------------------------------------
AI_SERVICE_PORT=8000
AI_SERVICE_URL=http://localhost:8000
AI_SERVICE_API_KEY=<optional-in-dev-required-in-prod>
AI_CORS_ORIGINS=http://localhost:3000,http://localhost:3001
CLAUDE_API_KEY=
# -----------------------------------------------------------------------------
@@ -221,7 +238,10 @@ SENTRY_PROJECT=
# Must be exactly 64 hex characters (32 bytes).
# openssl rand -hex 32
# -----------------------------------------------------------------------------
KYC_ENCRYPTION_KEY=<generate with: openssl rand -hex 32>
FIELD_ENCRYPTION_KEY=<generate with: openssl rand -hex 32>
FIELD_ENCRYPTION_KEY_VERSION=1
# Backward-compatible fallback accepted by the API; prefer FIELD_ENCRYPTION_KEY.
KYC_ENCRYPTION_KEY=
KYC_ENCRYPTION_KEY_VERSION=1
# -----------------------------------------------------------------------------

View File

@@ -51,6 +51,10 @@ CORS_ORIGINS=http://localhost:3010,http://localhost:3000
# Bcrypt (fast rounds for test — production uses 12+)
BCRYPT_ROUNDS=4
# Seeded admin used by E2E happy-path admin flows
SEED_DEFAULT_PASSWORD=Test@1234!
E2E_ADMIN_PHONE=0876677771
# OAuth (test stubs)
GOOGLE_CLIENT_ID=test-google-client-id
GOOGLE_CLIENT_SECRET=test-google-client-secret
@@ -70,3 +74,8 @@ MOMO_SECRET_KEY=TEST_MOMO_SECRET_KEY
ZALOPAY_APP_ID=TEST_ZALOPAY_APP
ZALOPAY_KEY1=TEST_ZALOPAY_KEY1
ZALOPAY_KEY2=TEST_ZALOPAY_KEY2
BANK_TRANSFER_ACCOUNT_NUMBER=0123456789
BANK_TRANSFER_BANK_NAME=Vietcombank
BANK_TRANSFER_ACCOUNT_HOLDER=CONG_TY_GOODGO
BANK_TRANSFER_WEBHOOK_SECRET=test-bank-transfer-webhook-secret-minimum-32-chars
BANK_TRANSFER_INSTRUCTIONS_URL=http://localhost:3010/thanh-toan/chuyen-khoan

View File

@@ -149,79 +149,10 @@ jobs:
name: E2E Tests
needs: ci
runs-on: ubuntu-latest
timeout-minutes: 20
services:
postgres:
image: postgis/postgis:16-3.4
env:
POSTGRES_DB: goodgo_test
POSTGRES_USER: goodgo
POSTGRES_PASSWORD: goodgo_test_secret
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U goodgo -d goodgo_test"
--health-interval 10s
--health-timeout 5s
--health-retries 5
--health-start-period 30s
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
typesense:
image: typesense/typesense:27.1
ports:
- 8108:8108
env:
TYPESENSE_API_KEY: ts_ci_key
TYPESENSE_DATA_DIR: /data
options: >-
--health-cmd "curl -sf http://localhost:8108/health || exit 1"
--health-interval 10s
--health-timeout 5s
--health-retries 5
minio:
image: minio/minio:latest
ports:
- 9000:9000
env:
MINIO_ROOT_USER: ci_minio_user
MINIO_ROOT_PASSWORD: ci_minio_secret_key_32chars!!
options: >-
--health-cmd "curl -sf http://localhost:9000/minio/health/live || exit 1"
--health-interval 10s
--health-timeout 5s
--health-retries 5
timeout-minutes: 45
env:
DATABASE_URL: postgresql://goodgo:goodgo_test_secret@localhost:5432/goodgo_test
REDIS_URL: redis://localhost:6379
TYPESENSE_URL: http://localhost:8108
TYPESENSE_HOST: localhost
TYPESENSE_PORT: 8108
TYPESENSE_API_KEY: ts_ci_key
MINIO_ENDPOINT: localhost
MINIO_PORT: 9000
MINIO_ACCESS_KEY: ci_minio_user
MINIO_SECRET_KEY: ci_minio_secret_key_32chars!!
MINIO_BUCKET: goodgo-uploads
NODE_ENV: test
JWT_SECRET: e2e-test-jwt-secret-key
JWT_REFRESH_SECRET: e2e-test-refresh-secret-key
VNPAY_TMN_CODE: TESTCODE
VNPAY_HASH_SECRET: TESTHASHSECRET
VNPAY_URL: https://sandbox.vnpayment.vn/paymentv2/vpcpay.html
VNPAY_RETURN_URL: http://localhost:3000/payment/return
CI: true
steps:
- name: Checkout
@@ -239,6 +170,12 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Load E2E environment
run: awk 'NF && $1 !~ /^#/' .env.test >> "$GITHUB_ENV"
- name: Start CI service stack
run: docker compose --env-file .env.ci -f docker-compose.ci.yml up -d --wait
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
@@ -281,3 +218,7 @@ jobs:
name: playwright-traces
path: test-results/
retention-days: 7
- name: Stop CI service stack
if: always()
run: docker compose --env-file .env.ci -f docker-compose.ci.yml down -v

View File

@@ -1,61 +0,0 @@
name: CodeQL Analysis
on:
push:
branches: [master]
pull_request:
branches: [master]
schedule:
# Run weekly on Monday at 06:17 UTC — off-peak to avoid :00/:30 congestion
- cron: "17 6 * * 1"
concurrency:
group: codeql-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: read
contents: read
security-events: write
jobs:
analyze:
name: CodeQL (${{ matrix.language }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
language: [javascript-typescript]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
# Use extended security queries for deeper analysis
queries: security-extended,security-and-quality
config: |
paths:
- apps/
- libs/
paths-ignore:
- node_modules/
- "**/dist/"
- "**/*.spec.ts"
- "**/*.test.ts"
- "**/__tests__/"
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
# SARIF results are automatically uploaded to GitHub Security tab
upload: always

View File

@@ -23,6 +23,53 @@ env:
REGISTRY_URL: ghcr.io/${{ github.repository_owner }}
jobs:
deploy-config:
name: Check Deploy Configuration
runs-on: ubuntu-latest
outputs:
staging_ready: ${{ steps.check.outputs.staging_ready }}
production_ready: ${{ steps.check.outputs.production_ready }}
steps:
- name: Check required deploy secrets
id: check
env:
TARGET_ENV: ${{ inputs.environment }}
STAGING_HOST: ${{ secrets.STAGING_HOST }}
STAGING_USER: ${{ secrets.STAGING_USER }}
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
STAGING_URL: ${{ secrets.STAGING_URL }}
STAGING_API_URL: ${{ secrets.STAGING_API_URL }}
PRODUCTION_HOST: ${{ secrets.PRODUCTION_HOST }}
PRODUCTION_USER: ${{ secrets.PRODUCTION_USER }}
PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
PRODUCTION_URL: ${{ secrets.PRODUCTION_URL }}
PRODUCTION_API_URL: ${{ secrets.PRODUCTION_API_URL }}
run: |
STAGING_READY=false
PRODUCTION_READY=false
if [ -n "$STAGING_HOST" ] && [ -n "$STAGING_USER" ] && [ -n "$STAGING_SSH_KEY" ] && [ -n "$STAGING_URL" ] && [ -n "$STAGING_API_URL" ]; then
STAGING_READY=true
fi
if [ -n "$PRODUCTION_HOST" ] && [ -n "$PRODUCTION_USER" ] && [ -n "$PRODUCTION_SSH_KEY" ] && [ -n "$PRODUCTION_URL" ] && [ -n "$PRODUCTION_API_URL" ]; then
PRODUCTION_READY=true
fi
echo "staging_ready=$STAGING_READY" >> "$GITHUB_OUTPUT"
echo "production_ready=$PRODUCTION_READY" >> "$GITHUB_OUTPUT"
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ "$TARGET_ENV" = "staging" ] && [ "$STAGING_READY" != "true" ]; then
echo "Missing required staging deploy secrets; configure STAGING_HOST, STAGING_USER, STAGING_SSH_KEY, STAGING_URL, and STAGING_API_URL."
exit 1
fi
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ "$TARGET_ENV" = "production" ] && [ "$PRODUCTION_READY" != "true" ]; then
echo "Missing required production deploy secrets; configure PRODUCTION_HOST, PRODUCTION_USER, PRODUCTION_SSH_KEY, PRODUCTION_URL, and PRODUCTION_API_URL."
exit 1
fi
build-api:
name: Build API Image
runs-on: ubuntu-latest
@@ -154,11 +201,14 @@ jobs:
deploy-staging:
name: Deploy to Staging
needs: [build-api, build-web, build-ai]
needs: [deploy-config, build-api, build-web, build-ai]
if: >-
github.ref == 'refs/heads/develop' ||
(github.event_name == 'push' && github.ref == 'refs/heads/master') ||
(github.event_name == 'workflow_dispatch' && inputs.environment == 'staging')
needs.deploy-config.outputs.staging_ready == 'true' &&
(
github.ref == 'refs/heads/develop' ||
(github.event_name == 'push' && github.ref == 'refs/heads/master') ||
(github.event_name == 'workflow_dispatch' && inputs.environment == 'staging')
)
runs-on: ubuntu-latest
environment: staging
@@ -221,17 +271,17 @@ jobs:
[ "\$PREV_WEB" != "none" ] && docker tag "\$PREV_WEB" goodgo-web:rollback 2>/dev/null || true
[ "\$PREV_AI" != "none" ] && docker tag "\$PREV_AI" goodgo-ai-services:rollback 2>/dev/null || true
# Pull new images
docker compose -f docker-compose.prod.yml pull api web ai-services
# Apply migrations with the newly pulled API image before switching app containers.
docker compose -f docker-compose.prod.yml run --rm --no-deps api \
npx prisma migrate deploy --schema /app/prisma/schema.prisma
# Rolling update — zero downtime
docker compose -f docker-compose.prod.yml up -d --no-deps --wait api
docker compose -f docker-compose.prod.yml up -d --no-deps --wait web
docker compose -f docker-compose.prod.yml up -d --no-deps --wait ai-services
# Run database migrations
docker compose -f docker-compose.prod.yml exec -T api npx prisma migrate deploy
# NOTE: docker image prune is NOT run here — it runs after smoke tests pass
DEPLOY_SCRIPT
@@ -394,8 +444,11 @@ jobs:
rollback-staging:
name: Rollback Staging
needs: [deploy-staging, smoke-test-staging]
if: failure()
needs: [deploy-config, deploy-staging, smoke-test-staging]
if: >-
always() &&
needs.deploy-config.outputs.staging_ready == 'true' &&
(needs.deploy-staging.result == 'failure' || needs.smoke-test-staging.result == 'failure')
runs-on: ubuntu-latest
environment: staging
@@ -462,8 +515,11 @@ jobs:
deploy-production:
name: Deploy to Production
needs: [build-api, build-web, build-ai]
if: inputs.environment == 'production'
needs: [deploy-config, build-api, build-web, build-ai]
if: >-
github.event_name == 'workflow_dispatch' &&
inputs.environment == 'production' &&
needs.deploy-config.outputs.production_ready == 'true'
runs-on: ubuntu-latest
environment: production
@@ -507,13 +563,15 @@ jobs:
docker compose -f docker-compose.prod.yml pull api web ai-services
# Apply migrations with the newly pulled API image before switching app containers.
docker compose -f docker-compose.prod.yml run --rm --no-deps api \
npx prisma migrate deploy --schema /app/prisma/schema.prisma
# Rolling update with health checks
docker compose -f docker-compose.prod.yml up -d --no-deps --wait api
docker compose -f docker-compose.prod.yml up -d --no-deps --wait web
docker compose -f docker-compose.prod.yml up -d --no-deps --wait ai-services
docker compose -f docker-compose.prod.yml exec -T api npx prisma migrate deploy
# NOTE: docker image prune is NOT run here — it runs after smoke tests pass
DEPLOY_SCRIPT
@@ -652,8 +710,11 @@ jobs:
rollback-production:
name: Rollback Production
needs: [smoke-test-production]
if: failure()
needs: [deploy-config, deploy-production, smoke-test-production]
if: >-
always() &&
needs.deploy-config.outputs.production_ready == 'true' &&
(needs.deploy-production.result == 'failure' || needs.smoke-test-production.result == 'failure')
runs-on: ubuntu-latest
environment: production

View File

@@ -14,98 +14,10 @@ jobs:
e2e:
name: Playwright E2E
runs-on: ubuntu-latest
timeout-minutes: 20
services:
postgres:
image: postgis/postgis:16-3.4
env:
POSTGRES_DB: goodgo_test
POSTGRES_USER: goodgo
POSTGRES_PASSWORD: goodgo_test_secret
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U goodgo -d goodgo_test"
--health-interval 10s
--health-timeout 5s
--health-retries 5
--health-start-period 30s
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
typesense:
image: typesense/typesense:27.1
ports:
- 8108:8108
env:
TYPESENSE_API_KEY: ts_ci_key
TYPESENSE_DATA_DIR: /data
options: >-
--health-cmd "curl -sf http://localhost:8108/health || exit 1"
--health-interval 10s
--health-timeout 5s
--health-retries 5
minio:
image: minio/minio:latest
ports:
- 9000:9000
env:
MINIO_ROOT_USER: ${{ vars.CI_MINIO_ACCESS_KEY || 'ci_minio_user' }}
MINIO_ROOT_PASSWORD: ${{ vars.CI_MINIO_SECRET_KEY || 'ci_minio_secret_key_32chars!!' }}
options: >-
--health-cmd "curl -sf http://localhost:9000/minio/health/live || exit 1"
--health-interval 10s
--health-timeout 5s
--health-retries 5
timeout-minutes: 45
env:
DATABASE_URL: postgresql://goodgo:goodgo_test_secret@localhost:5432/goodgo_test
REDIS_URL: redis://localhost:6379
REDIS_HOST: localhost
REDIS_PORT: 6379
TYPESENSE_URL: http://localhost:8108
TYPESENSE_HOST: localhost
TYPESENSE_PORT: 8108
TYPESENSE_PROTOCOL: http
TYPESENSE_API_KEY: ts_ci_key
MINIO_ENDPOINT: localhost
MINIO_PORT: 9000
MINIO_ACCESS_KEY: ${{ vars.CI_MINIO_ACCESS_KEY || 'ci_minio_user' }}
MINIO_SECRET_KEY: ${{ vars.CI_MINIO_SECRET_KEY || 'ci_minio_secret_key_32chars!!' }}
MINIO_BUCKET: goodgo-uploads
NODE_ENV: test
CI: true
# API and Web ports for Playwright webServer
API_PORT: 3001
WEB_PORT: 3000
API_BASE_URL: http://localhost:3001/api/v1/
WEB_BASE_URL: http://localhost:3000
NEXT_PUBLIC_API_URL: http://localhost:3001/api/v1
JWT_SECRET: e2e-test-jwt-secret-key-minimum-32-chars-long-enough
JWT_REFRESH_SECRET: e2e-test-refresh-secret-key-minimum-32-chars-ok
JWT_EXPIRES_IN: 15m
JWT_REFRESH_EXPIRES_IN: 7d
BCRYPT_ROUNDS: 4
VNPAY_TMN_CODE: TESTCODE
VNPAY_HASH_SECRET: TESTHASHSECRETTESTHASHSECRETTEST
VNPAY_URL: https://sandbox.vnpayment.vn/paymentv2/vpcpay.html
VNPAY_RETURN_URL: http://localhost:3000/payment/return
GOOGLE_CLIENT_ID: test-google-client-id
GOOGLE_CLIENT_SECRET: test-google-client-secret
GOOGLE_CALLBACK_URL: http://localhost:3001/api/v1/auth/google/callback
ZALO_APP_ID: test-zalo-app-id
ZALO_APP_SECRET: test-zalo-app-secret
ZALO_CALLBACK_URL: http://localhost:3001/api/v1/auth/zalo/callback
steps:
- name: Checkout
@@ -123,6 +35,12 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Load E2E environment
run: awk 'NF && $1 !~ /^#/' .env.test >> "$GITHUB_ENV"
- name: Start CI service stack
run: docker compose --env-file .env.ci -f docker-compose.ci.yml up -d --wait
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
@@ -165,3 +83,7 @@ jobs:
name: playwright-traces
path: test-results/
retention-days: 7
- name: Stop CI service stack
if: always()
run: docker compose --env-file .env.ci -f docker-compose.ci.yml down -v

View File

@@ -15,7 +15,6 @@ concurrency:
permissions:
contents: read
security-events: write
jobs:
# ── Dependency Audit ─────────────────────────────────────────────
@@ -96,25 +95,8 @@ jobs:
cache-from: type=gha,scope=api-scan
cache-to: type=gha,mode=max,scope=api-scan
- name: Run Trivy vulnerability scanner (API)
uses: aquasecurity/trivy-action@0.28.0
with:
image-ref: "goodgo-api:scan"
format: "sarif"
output: "trivy-api-results.sarif"
severity: "CRITICAL,HIGH"
# Ignore unfixed vulns to reduce noise
ignore-unfixed: true
- name: Upload Trivy SARIF (API)
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: "trivy-api-results.sarif"
category: "trivy-api"
- name: Trivy table output (API)
uses: aquasecurity/trivy-action@0.28.0
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: "goodgo-api:scan"
format: "table"
@@ -144,24 +126,8 @@ jobs:
cache-from: type=gha,scope=web-scan
cache-to: type=gha,mode=max,scope=web-scan
- name: Run Trivy vulnerability scanner (Web)
uses: aquasecurity/trivy-action@0.28.0
with:
image-ref: "goodgo-web:scan"
format: "sarif"
output: "trivy-web-results.sarif"
severity: "CRITICAL,HIGH"
ignore-unfixed: true
- name: Upload Trivy SARIF (Web)
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: "trivy-web-results.sarif"
category: "trivy-web"
- name: Trivy table output (Web)
uses: aquasecurity/trivy-action@0.28.0
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: "goodgo-web:scan"
format: "table"
@@ -191,24 +157,8 @@ jobs:
cache-from: type=gha,scope=ai-scan
cache-to: type=gha,mode=max,scope=ai-scan
- name: Run Trivy vulnerability scanner (AI)
uses: aquasecurity/trivy-action@0.28.0
with:
image-ref: "goodgo-ai:scan"
format: "sarif"
output: "trivy-ai-results.sarif"
severity: "CRITICAL,HIGH"
ignore-unfixed: true
- name: Upload Trivy SARIF (AI)
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: "trivy-ai-results.sarif"
category: "trivy-ai"
- name: Trivy table output (AI)
uses: aquasecurity/trivy-action@0.28.0
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: "goodgo-ai:scan"
format: "table"
@@ -225,26 +175,8 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Run Trivy filesystem scanner
uses: aquasecurity/trivy-action@0.28.0
with:
scan-type: "fs"
scan-ref: "."
format: "sarif"
output: "trivy-fs-results.sarif"
severity: "CRITICAL,HIGH"
ignore-unfixed: true
scanners: "vuln,secret,misconfig"
- name: Upload Trivy SARIF (filesystem)
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: "trivy-fs-results.sarif"
category: "trivy-filesystem"
- name: Trivy filesystem table output
uses: aquasecurity/trivy-action@0.28.0
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: "fs"
scan-ref: "."

3
.gitignore vendored
View File

@@ -36,6 +36,9 @@ load-tests/results/*.json
npm-debug.log*
pnpm-debug.log*
# Redis dump (created when running redis locally without persistence config)
*.rdb
# personal notes / Obsidian
.obsidian/
TEC/

97
AGENTS.md Normal file
View File

@@ -0,0 +1,97 @@
# GoodGo Platform
Vietnamese real estate platform — monorepo powered by pnpm workspaces + Turborepo.
## Quick Start
```bash
pnpm install
pnpm db:generate # Generate Prisma client
pnpm db:migrate:dev # Run migrations (needs PostgreSQL 16 + PostGIS)
pnpm db:seed # Seed sample data (users, listings, districts)
pnpm dev # Start all apps (API :3001, Web :3000)
```
## Architecture
- **apps/api** — NestJS backend (CQRS, DDD, clean architecture)
- **apps/web** — Next.js 15 frontend (App Router, Tailwind, Zustand)
- **libs/ai-services** — Python FastAPI AI/ML services (AVM, content moderation, NLP)
- **libs/mcp-servers** — MCP tool server library (property search, analytics, valuation)
- **prisma/** — Schema, migrations, seed scripts
- **e2e/** — Playwright E2E tests (API + Web projects)
## Key Commands
| Command | Description |
|---------|-------------|
| `pnpm lint` | ESLint (auto-fixable with `--fix`) |
| `pnpm typecheck` | TypeScript type checking |
| `pnpm test` | Unit tests via Vitest (API only) |
| `pnpm build` | Production build (all packages) |
| `pnpm test:e2e` | Playwright E2E tests |
| `pnpm db:studio` | Prisma Studio GUI |
## Tech Stack
- **Runtime**: Node.js >= 22, pnpm 10
- **Backend**: NestJS, Prisma ORM, PostgreSQL 16 + PostGIS, Redis
- **Frontend**: Next.js 15, React 18, Tailwind CSS 3, Zustand, Mapbox GL
- **Testing**: Vitest (unit), Playwright (E2E)
- **CI**: GitHub Actions (lint → typecheck → test → build)
## Project Structure (API)
```
apps/api/src/modules/
auth/ — Authentication (JWT, OAuth, refresh tokens, CSRF)
listings/ — Property listings CRUD
payments/ — VNPay, MoMo, ZaloPay payment integration
subscriptions/ — Plans, quotas, usage tracking
admin/ — Moderation, KYC, user management, audit logs
analytics/ — Market data, heatmaps, price trends, AVM
search/ — Geo search, full-text search (Typesense), saved searches
notifications/ — Email, in-app notifications
agents/ — Agent profiles, quality scores
inquiries/ — Property inquiry management
leads/ — Lead tracking and conversion
reviews/ — Property reviews and ratings
health/ — Liveness and readiness probes
metrics/ — Prometheus metrics, web vitals
mcp/ — MCP tool server endpoints
shared/ — Domain primitives, guards, pipes, logging
```
Each module follows DDD layers: `domain/``application/``infrastructure/``presentation/`.
## Project Structure (Libs)
```
libs/
ai-services/ — Python FastAPI AI/ML services (AVM, content moderation, NLP)
mcp-servers/ — MCP tool server library (property search, analytics, valuation)
```
## Database
- PostgreSQL 16 with PostGIS extension for geospatial queries
- 22 models (User, Property, Listing, Payment, Subscription, etc.)
- Migrations in `prisma/migrations/`
- Seed data covers: users, agents, Ho Chi Minh City districts/wards, sample properties, subscription plans
## Environment Variables
Required in `.env`:
- `DATABASE_URL` — PostgreSQL connection string
- `JWT_SECRET`, `JWT_REFRESH_SECRET` — Auth tokens
- `VNPAY_*` — Payment gateway config
- `MAPBOX_TOKEN` — Map rendering (frontend)
- `REDIS_URL` — Cache layer (optional for dev)
## Conventions
- Import order enforced by eslint-plugin-import-x (external → internal → relative)
- Path aliases: `@modules/*` in API, `@/*` in Web
- Vietnamese UI text throughout (property types, districts, currency in VND)
- All handlers return typed `Result<T>` or throw `DomainException`
- Commit messages follow conventional commits

View File

@@ -11,7 +11,7 @@ set -e
if [ "${RUN_MIGRATIONS}" = "true" ]; then
echo "[entrypoint] Running Prisma migrations..."
npx prisma migrate deploy --schema ./prisma/schema.prisma
npx prisma migrate deploy --schema /app/prisma/schema.prisma
echo "[entrypoint] Migrations complete."
fi

View File

@@ -20,7 +20,9 @@ import { McpIntegrationModule } from '@modules/mcp';
import { MessagingModule } from '@modules/messaging';
import { HttpMetricsInterceptor, MetricsModule } from '@modules/metrics';
import { NotificationsModule } from '@modules/notifications';
import { OsmSyncModule } from '@modules/osm-sync/osm-sync.module';
import { PaymentsModule } from '@modules/payments';
import { PoiModule } from '@modules/poi/poi.module';
import { ProjectsModule } from '@modules/projects';
import { QueuesModule } from '@modules/queues/queues.module';
import { ReportsModule } from '@modules/reports';
@@ -58,7 +60,9 @@ import { AppController } from './app.controller';
FavoritesModule,
SearchModule,
NotificationsModule,
OsmSyncModule,
PaymentsModule,
PoiModule,
SubscriptionsModule,
AdminModule,
AnalyticsModule,

View File

@@ -1,3 +1,4 @@
import { Prisma } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import {
type DashboardStats,
@@ -80,7 +81,12 @@ export async function getRevenueStats(
return cached.data;
}
const truncUnit = groupBy === 'day' ? 'day' : 'month';
// Postgres can't prove that `DATE_TRUNC($n, ...)` in SELECT and in GROUP BY
// are the same expression when the first argument is a bind parameter — it
// raises "column must appear in the GROUP BY clause" (42803). Inline the
// unit as a raw fragment instead. `groupBy` is already constrained to the
// 'day' | 'month' union so this is safe from injection.
const truncUnit = groupBy === 'day' ? Prisma.sql`'day'` : Prisma.sql`'month'`;
const rows = await prisma.$queryRaw<RevenueRawRow[]>`
SELECT

View File

@@ -1,6 +1,7 @@
import { forwardRef, Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { makeCounterProvider, makeHistogramProvider } from '@willsoto/nestjs-prometheus';
import { AdminModule } from '@modules/admin';
import { ListingsModule } from '@modules/listings';
import { ProjectsModule } from '@modules/projects';
import { GenerateReportHandler } from './application/commands/generate-report/generate-report.handler';
@@ -84,7 +85,12 @@ const EventHandlers = [
];
@Module({
imports: [CqrsModule, forwardRef(() => ListingsModule), ProjectsModule],
imports: [
CqrsModule,
forwardRef(() => ListingsModule),
ProjectsModule,
forwardRef(() => AdminModule), // for AI_CONFIG_PROVIDER (used by AI advice handlers)
],
controllers: [AnalyticsController, AvmController],
providers: [
// AI service client

View File

@@ -1,9 +1,14 @@
import { HttpStatus, Inject } from '@nestjs/common';
import { QueryBus, QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
// Direct internal path: barrel `@modules/listings` exports `ListingsModule`
// first, which transitively imports the analytics handler back here. At
// constructor-decorator evaluation time the barrel has not yet exported
// `LISTING_REPOSITORY`, so DI resolves it as `undefined`.
// eslint-disable-next-line no-restricted-imports -- circular-import workaround; see comment above
import {
LISTING_REPOSITORY,
type IListingRepository,
} from '@modules/listings';
} from '@modules/listings/domain/repositories/listing.repository';
import {
AI_CONFIG_PROVIDER,
DomainException,

View File

@@ -62,7 +62,23 @@ import { RolesGuard } from '../guards/roles.guard';
const IS_PRODUCTION = process.env['NODE_ENV'] === 'production';
const IS_TEST = process.env['NODE_ENV'] === 'test';
const AUTH_RATE_LIMIT = IS_TEST ? 10_000 : 5;
/**
* Hourly rate limit for auth endpoints. Default 5 is the production
* safety threshold; raise via env in dev/staging when exercising flows
* (e.g. `AUTH_RATE_LIMIT=200` in the cluster ConfigMap so testers
* don't lock themselves out after a few attempts).
*/
const AUTH_RATE_LIMIT = (() => {
if (IS_TEST) return 10_000;
const fromEnv = Number(process.env['AUTH_RATE_LIMIT']);
return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : 5;
})();
/** Per-IP burst limit for the login / register endpoints (per minute). */
const AUTH_PER_IP_LIMIT = (() => {
if (IS_TEST) return 10_000;
const fromEnv = Number(process.env['AUTH_PER_IP_LIMIT']);
return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : 5;
})();
const ACCESS_TOKEN_MAX_AGE = 15 * 60 * 1000; // 15 minutes
const REFRESH_TOKEN_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days
const AUTH_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days
@@ -109,7 +125,7 @@ export class AuthController {
) {}
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 3, windowSeconds: 60, keyStrategy: 'ip' })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'ip' })
@UseGuards(EndpointRateLimitGuard)
@Post('register')
@ApiOperation({ summary: 'Register a new user' })
@@ -132,7 +148,7 @@ export class AuthController {
}
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'ip' })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'ip' })
@UseGuards(EndpointRateLimitGuard, LocalAuthGuard)
@Post('login')
@ApiOperation({ summary: 'Login with phone and password' })
@@ -198,7 +214,7 @@ export class AuthController {
}
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 3, windowSeconds: 60, keyStrategy: 'ip' })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'ip' })
@UseGuards(EndpointRateLimitGuard)
@Post('forgot-password')
@ApiOperation({
@@ -215,7 +231,7 @@ export class AuthController {
}
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'ip' })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'ip' })
@UseGuards(EndpointRateLimitGuard)
@Post('reset-password')
@ApiOperation({ summary: 'Reset password using OTP code' })
@@ -231,7 +247,7 @@ export class AuthController {
}
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: 20 } })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'ip' })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'ip' })
@UseGuards(EndpointRateLimitGuard)
@Post('exchange-token')
@ApiOperation({ summary: 'Exchange OAuth token pair for httpOnly cookies' })
@@ -286,7 +302,7 @@ export class AuthController {
}
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'user' })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'user' })
@UseGuards(JwtAuthGuard, EndpointRateLimitGuard)
@Post('profile/verify-phone')
@ApiBearerAuth('JWT')
@@ -307,7 +323,7 @@ export class AuthController {
}
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'user' })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'user' })
@UseGuards(JwtAuthGuard, EndpointRateLimitGuard)
@Post('profile/verify-email')
@ApiBearerAuth('JWT')

View File

@@ -0,0 +1,11 @@
/**
* Toggle the `osmLocked` flag on a park. When locked, the OSM sync cron
* skips this row entirely — useful when admin has curated values that
* conflict with what OSM contributors keep changing.
*/
export class LockOsmParkCommand {
constructor(
public readonly parkId: string,
public readonly locked: boolean,
) {}
}

View File

@@ -0,0 +1,24 @@
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { LoggerService, PrismaService } from '@modules/shared';
import { LockOsmParkCommand } from './lock-osm-park.command';
@CommandHandler(LockOsmParkCommand)
export class LockOsmParkHandler implements ICommandHandler<LockOsmParkCommand> {
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async execute(cmd: LockOsmParkCommand): Promise<{ id: string; locked: boolean }> {
const park = await this.prisma.industrialPark.update({
where: { id: cmd.parkId },
data: { osmLocked: cmd.locked },
select: { id: true, osmLocked: true },
});
this.logger.log(
`Park ${park.id} osmLocked → ${park.osmLocked}`,
this.constructor.name,
);
return { id: park.id, locked: park.osmLocked };
}
}

View File

@@ -0,0 +1,15 @@
/**
* Promote a raw OSM-imported industrial park to the public catalogue.
*
* - Flips `dataSource` from `OSM` → `OSM_PROMOTED`
* - Sets `isPublic = true`
* - Optionally locks fields the admin has just curated so the next OSM
* sync doesn't overwrite them.
*/
export class PromoteOsmParkCommand {
constructor(
public readonly parkId: string,
/** Field names to add to `lockedFields`. Empty array = no lock. */
public readonly lockFields: string[] = [],
) {}
}

View File

@@ -0,0 +1,44 @@
import { HttpStatus } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, ErrorCode, LoggerService, PrismaService } from '@modules/shared';
import { PromoteOsmParkCommand } from './promote-osm-park.command';
@CommandHandler(PromoteOsmParkCommand)
export class PromoteOsmParkHandler implements ICommandHandler<PromoteOsmParkCommand> {
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async execute(cmd: PromoteOsmParkCommand): Promise<{ id: string }> {
const park = await this.prisma.industrialPark.findUnique({
where: { id: cmd.parkId },
select: { id: true, dataSource: true, lockedFields: true },
});
if (!park) {
throw new DomainException(
ErrorCode.NOT_FOUND,
`Không tìm thấy KCN ${cmd.parkId}`,
HttpStatus.NOT_FOUND,
);
}
if (park.dataSource === 'MANUAL') {
// Already in the public catalog as a manual seed; nothing to promote.
return { id: park.id };
}
const newLocked = Array.from(new Set([...park.lockedFields, ...cmd.lockFields]));
await this.prisma.industrialPark.update({
where: { id: cmd.parkId },
data: {
dataSource: 'OSM_PROMOTED',
isPublic: true,
lockedFields: newLocked,
},
});
this.logger.log(
`Promoted park ${cmd.parkId} from OSM → OSM_PROMOTED (locked: ${newLocked.join(', ')})`,
this.constructor.name,
);
return { id: park.id };
}
}

View File

@@ -0,0 +1,145 @@
import { InternalServerErrorException } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import type { Feature, FeatureCollection } from 'geojson';
import { LoggerService, PrismaService } from '@modules/shared';
import { GetIndustrialParksByBboxQuery } from './get-industrial-parks-by-bbox.query';
interface BboxRow {
id: string;
slug: string;
name: string;
status: string;
province: string;
data_source: string;
occupancy_rate: number;
total_area_ha: number;
tenant_count: number;
point: string; // GeoJSON Point as text (ST_AsGeoJSON)
polygon: string | null; // GeoJSON MultiPolygon, only when zoom >= 12
}
export interface IndustrialParksGeoCollection extends FeatureCollection {
/** Quick metadata so the client can show "showing N of M parks" */
meta: {
count: number;
truncated: boolean;
zoom: number;
};
}
/** Zoom threshold above which the boundary polygon is included. Below this
* we send only the centroid Point — enough to cluster + render dots. */
const BOUNDARY_ZOOM = 12;
@QueryHandler(GetIndustrialParksByBboxQuery)
export class GetIndustrialParksByBboxHandler
implements IQueryHandler<GetIndustrialParksByBboxQuery, IndustrialParksGeoCollection>
{
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async execute(
q: GetIndustrialParksByBboxQuery,
): Promise<IndustrialParksGeoCollection> {
try {
const { south, west, north, east } = q.bbox;
const includeBoundary = q.zoom >= BOUNDARY_ZOOM;
const limit = Math.min(Math.max(q.limit, 1), 5000);
// Visibility logic:
// - Public callers: only `isPublic = true` rows (MANUAL + OSM_PROMOTED).
// - Admin callers (`includeOsmRaw=true`): also include OSM raw rows
// regardless of `isPublic`, so the admin map can preview the
// review queue. Other rows still respect `isPublic`.
const visibilityClause = q.includeOsmRaw
? `(
("isPublic" = true AND "dataSource"::text IN ('MANUAL', 'OSM_PROMOTED'))
OR "dataSource"::text = 'OSM'
)`
: `("isPublic" = true AND "dataSource"::text IN ('MANUAL', 'OSM_PROMOTED'))`;
// Single PostGIS query: bbox filter + optional polygon column.
// && is the bbox-intersect operator and uses the GiST index.
const rows = await this.prisma.$queryRawUnsafe<BboxRow[]>(
`
SELECT
id,
slug,
name,
status::text,
province,
"dataSource"::text AS data_source,
"occupancyRate" AS occupancy_rate,
"totalAreaHa" AS total_area_ha,
"tenantCount" AS tenant_count,
ST_AsGeoJSON(location) AS point,
${includeBoundary ? `ST_AsGeoJSON(boundary)` : `NULL::text`} AS polygon
FROM "IndustrialPark"
WHERE ${visibilityClause}
AND location && ST_MakeEnvelope($1, $2, $3, $4, 4326)
ORDER BY "totalAreaHa" DESC NULLS LAST
LIMIT ${limit + 1}
`,
west,
south,
east,
north,
);
const truncated = rows.length > limit;
const trimmed = truncated ? rows.slice(0, limit) : rows;
const features: Feature[] = trimmed.flatMap((r) => {
const properties = {
id: r.id,
slug: r.slug,
name: r.name,
status: r.status,
province: r.province,
dataSource: r.data_source,
occupancyRate: Number(r.occupancy_rate),
totalAreaHa: Number(r.total_area_ha),
tenantCount: Number(r.tenant_count),
};
const out: Feature[] = [];
if (r.point) {
out.push({
type: 'Feature',
id: `${r.id}:point`,
geometry: JSON.parse(r.point),
properties: { ...properties, _kind: 'point' },
});
}
if (r.polygon) {
out.push({
type: 'Feature',
id: `${r.id}:polygon`,
geometry: JSON.parse(r.polygon),
properties: { ...properties, _kind: 'polygon' },
});
}
return out;
});
return {
type: 'FeatureCollection',
features,
meta: {
count: trimmed.length,
truncated,
zoom: q.zoom,
},
};
} catch (err) {
this.logger.error(
`Failed to query parks by bbox: ${err instanceof Error ? err.message : err}`,
err instanceof Error ? err.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException(
'Không thể tải KCN theo khu vực. Vui lòng thử lại sau.',
);
}
}
}

View File

@@ -0,0 +1,20 @@
/**
* Spatial bbox query for the public KCN map. Returns a GeoJSON
* FeatureCollection of industrial parks intersecting the given viewport.
*
* - At low zoom we return Point centroids only (cluster on the client).
* - At high zoom (>= 12) we also include the MultiPolygon `boundary`
* so Mapbox can render the park outline.
*
* Visibility is filtered to MANUAL + OSM_PROMOTED rows by default
* (`includeOsmRaw=false`); admin tooling can pass `true` to see the raw
* OSM-imported parks awaiting review.
*/
export class GetIndustrialParksByBboxQuery {
constructor(
public readonly bbox: { south: number; west: number; north: number; east: number },
public readonly zoom: number,
public readonly includeOsmRaw: boolean = false,
public readonly limit: number = 1000,
) {}
}

View File

@@ -0,0 +1,143 @@
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { LoggerService, PrismaService } from '@modules/shared';
import { ListOsmPendingQuery } from './list-osm-pending.query';
export interface OsmPendingItem {
id: string;
slug: string;
name: string;
nameEn: string | null;
province: string;
district: string;
region: string;
status: string;
osmId: string;
osmType: string | null;
osmTags: unknown;
totalAreaHa: number;
developer: string;
operator: string | null;
osmLocked: boolean;
lastSyncedAt: Date | null;
latitude: number | null;
longitude: number | null;
}
export interface ListOsmPendingResult {
data: OsmPendingItem[];
total: number;
page: number;
limit: number;
totalPages: number;
}
@QueryHandler(ListOsmPendingQuery)
export class ListOsmPendingHandler implements IQueryHandler<ListOsmPendingQuery> {
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async execute(q: ListOsmPendingQuery): Promise<ListOsmPendingResult> {
const limit = Math.min(Math.max(q.limit, 1), 200);
const offset = (Math.max(q.page, 1) - 1) * limit;
const conditions: string[] = [`"dataSource"::text = 'OSM'`];
const values: unknown[] = [];
let p = 1;
if (q.province) {
conditions.push(`province = $${p++}`);
values.push(q.province);
}
if (q.region) {
conditions.push(`region::text = $${p++}`);
values.push(q.region);
}
if (q.query) {
conditions.push(
`(name ILIKE $${p} OR "nameEn" ILIKE $${p} OR developer ILIKE $${p})`,
);
values.push(`%${q.query}%`);
p += 1;
}
if (q.minAreaHa > 0) {
// Use COALESCE so rows whose area we couldn't compute (NODE-only
// imports) only show up when the admin explicitly drops the floor
// to 0.
conditions.push(`COALESCE("totalAreaHa", 0) >= $${p++}`);
values.push(q.minAreaHa);
}
const where = conditions.join(' AND ');
const [{ count }] = await this.prisma.$queryRawUnsafe<[{ count: bigint }]>(
`SELECT COUNT(*)::bigint AS count FROM "IndustrialPark" WHERE ${where}`,
...values,
);
const total = Number(count);
const rows = await this.prisma.$queryRawUnsafe<
Array<{
id: string;
slug: string;
name: string;
nameEn: string | null;
province: string;
district: string;
region: string;
status: string;
osmId: bigint;
osmType: string | null;
osmTags: unknown;
totalAreaHa: number;
developer: string;
operator: string | null;
osmLocked: boolean;
lastSyncedAt: Date | null;
lat: number | null;
lng: number | null;
}>
>(
`SELECT
id, slug, name, "nameEn", province, district,
region::text AS region, status::text AS status,
"osmId", "osmType"::text AS "osmType", "osmTags",
"totalAreaHa", developer, operator, "osmLocked", "lastSyncedAt",
ST_Y(location::geometry) AS lat, ST_X(location::geometry) AS lng
FROM "IndustrialPark"
WHERE ${where}
ORDER BY "totalAreaHa" DESC NULLS LAST, "lastSyncedAt" DESC NULLS LAST
LIMIT $${p++} OFFSET $${p}`,
...values,
limit,
offset,
);
return {
data: rows.map((r) => ({
id: r.id,
slug: r.slug,
name: r.name,
nameEn: r.nameEn,
province: r.province,
district: r.district,
region: r.region,
status: r.status,
osmId: r.osmId.toString(),
osmType: r.osmType,
osmTags: r.osmTags,
totalAreaHa: Number(r.totalAreaHa),
developer: r.developer,
operator: r.operator,
osmLocked: r.osmLocked,
lastSyncedAt: r.lastSyncedAt,
latitude: r.lat,
longitude: r.lng,
})),
total,
page: q.page,
limit,
totalPages: Math.ceil(total / limit),
};
}
}

View File

@@ -0,0 +1,19 @@
/**
* Admin OSM review queue — list raw OSM-imported parks that haven't yet
* been promoted to the public catalogue.
*
* `minAreaHa` lets admins skip the long tail of `landuse=industrial`
* features OSM tags that turn out to be single factories or warehouses
* (typically < 5 ha). The default of 50 ha surfaces "real" KCN first; pass
* `0` to see everything.
*/
export class ListOsmPendingQuery {
constructor(
public readonly page: number = 1,
public readonly limit: number = 50,
public readonly query?: string,
public readonly province?: string,
public readonly minAreaHa: number = 50,
public readonly region?: string,
) {}
}

View File

@@ -5,6 +5,8 @@ import { CreateIndustrialListingHandler } from './application/commands/create-in
import { CreateIndustrialParkHandler } from './application/commands/create-industrial-park/create-industrial-park.handler';
import { DeleteIndustrialListingHandler } from './application/commands/delete-industrial-listing/delete-industrial-listing.handler';
import { DeleteIndustrialParkHandler } from './application/commands/delete-industrial-park/delete-industrial-park.handler';
import { LockOsmParkHandler } from './application/commands/lock-osm-park/lock-osm-park.handler';
import { PromoteOsmParkHandler } from './application/commands/promote-osm-park/promote-osm-park.handler';
import { UpdateIndustrialListingHandler } from './application/commands/update-industrial-listing/update-industrial-listing.handler';
import { UpdateIndustrialParkHandler } from './application/commands/update-industrial-park/update-industrial-park.handler';
import { AnalyzeIndustrialLocationHandler } from './application/queries/analyze-industrial-location/analyze-industrial-location.handler';
@@ -12,12 +14,15 @@ import { CompareIndustrialParksHandler } from './application/queries/compare-ind
import { EstimateIndustrialRentHandler } from './application/queries/estimate-industrial-rent/estimate-industrial-rent.handler';
import { GetIndustrialListingHandler } from './application/queries/get-industrial-listing/get-industrial-listing.handler';
import { GetIndustrialParkHandler } from './application/queries/get-industrial-park/get-industrial-park.handler';
import { GetIndustrialParksByBboxHandler } from './application/queries/get-industrial-parks-by-bbox/get-industrial-parks-by-bbox.handler';
import { IndustrialMarketHandler } from './application/queries/industrial-market/industrial-market.handler';
import { IndustrialParkStatsHandler } from './application/queries/industrial-park-stats/industrial-park-stats.handler';
import { ListIndustrialListingsHandler } from './application/queries/list-industrial-listings/list-industrial-listings.handler';
import { ListIndustrialParksHandler } from './application/queries/list-industrial-parks/list-industrial-parks.handler';
import { ListOsmPendingHandler } from './application/queries/list-osm-pending/list-osm-pending.handler';
import { INDUSTRIAL_LISTING_REPOSITORY } from './domain/repositories/industrial-listing.repository';
import { INDUSTRIAL_PARK_REPOSITORY } from './domain/repositories/industrial-park.repository';
import { OsmSyncCronService } from './infrastructure/cron/osm-sync-cron.service';
import { PrismaIndustrialListingRepository } from './infrastructure/repositories/prisma-industrial-listing.repository';
import { PrismaIndustrialParkRepository } from './infrastructure/repositories/prisma-industrial-park.repository';
import { TypesenseIndustrialService } from './infrastructure/services/typesense-industrial.service';
@@ -31,18 +36,22 @@ const CommandHandlers = [
CreateIndustrialListingHandler,
UpdateIndustrialListingHandler,
DeleteIndustrialListingHandler,
PromoteOsmParkHandler,
LockOsmParkHandler,
];
const QueryHandlers = [
AnalyzeIndustrialLocationHandler,
EstimateIndustrialRentHandler,
GetIndustrialParkHandler,
GetIndustrialParksByBboxHandler,
ListIndustrialParksHandler,
CompareIndustrialParksHandler,
IndustrialParkStatsHandler,
IndustrialMarketHandler,
GetIndustrialListingHandler,
ListIndustrialListingsHandler,
ListOsmPendingHandler,
];
@Module({
@@ -52,6 +61,7 @@ const QueryHandlers = [
{ provide: INDUSTRIAL_PARK_REPOSITORY, useClass: PrismaIndustrialParkRepository },
{ provide: INDUSTRIAL_LISTING_REPOSITORY, useClass: PrismaIndustrialListingRepository },
TypesenseIndustrialService,
OsmSyncCronService,
...CommandHandlers,
...QueryHandlers,
],

View File

@@ -0,0 +1,96 @@
import { spawn } from 'node:child_process';
import * as path from 'node:path';
import { Injectable } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { LoggerService } from '@modules/shared';
/**
* Monthly OSM industrial-park reconciliation. Schedules the same script
* we run manually for the bulk import (`scripts/sync-osm-industrial-parks.ts`)
* — this keeps the import logic in one place. The cron is a thin
* orchestrator that:
*
* • Spawns the sync script (one chunk at a time to avoid Overpass 504s)
* • Logs stdout/stderr line-by-line into the application logger
* • Skips entirely if `OSM_SYNC_ENABLED !== 'true'` so dev environments
* don't accidentally call Overpass
*
* Because the script uses upsert keyed on `osmId` and respects the
* `osmLocked` / `lockedFields` columns from PR 1, replays are safe — new
* OSM entities are added, removed entities stay in the DB (admin can
* delete them via the review UI), and admin-edited fields are preserved.
*/
@Injectable()
export class OsmSyncCronService {
constructor(private readonly logger: LoggerService) {}
/** Run on the 1st of each month at 02:00 ICT. */
@Cron('0 2 1 * *', { timeZone: 'Asia/Ho_Chi_Minh' })
async monthlySync(): Promise<void> {
if (process.env['OSM_SYNC_ENABLED'] !== 'true') {
this.logger.log(
'OSM_SYNC_ENABLED != true — skipping monthly sync.',
'OsmSyncCronService',
);
return;
}
this.logger.log('Starting monthly OSM sync…', 'OsmSyncCronService');
const chunks = ['north', 'northCentral', 'southCentral', 'south'];
for (const chunk of chunks) {
try {
await this.runChunk(chunk);
} catch (err) {
this.logger.error(
`OSM sync chunk "${chunk}" failed: ${err instanceof Error ? err.message : err}`,
err instanceof Error ? err.stack : undefined,
'OsmSyncCronService',
);
// Continue with the next chunk — partial success is better than
// failing the whole pass.
}
}
this.logger.log('Monthly OSM sync complete.', 'OsmSyncCronService');
}
private runChunk(chunk: string): Promise<void> {
return new Promise((resolve, reject) => {
const scriptPath = path.resolve(
__dirname,
'../../../../../../..',
'scripts/sync-osm-industrial-parks.ts',
);
const child = spawn(
'pnpm',
['tsx', scriptPath, `--chunk=${chunk}`],
{
cwd: path.resolve(__dirname, '../../../../../../..'),
env: {
...process.env,
NODE_OPTIONS: '-r dotenv/config',
DOTENV_CONFIG_PATH: '.env',
},
},
);
child.stdout?.on('data', (b) => {
for (const line of b.toString().split('\n')) {
if (line.trim()) {
this.logger.log(`[${chunk}] ${line.trim()}`, 'OsmSyncCronService');
}
}
});
child.stderr?.on('data', (b) => {
for (const line of b.toString().split('\n')) {
if (line.trim()) {
this.logger.warn(`[${chunk}] ${line.trim()}`, 'OsmSyncCronService');
}
}
});
child.on('error', reject);
child.on('exit', (code) => {
if (code === 0) resolve();
else reject(new Error(`sync-osm-industrial-parks exited ${code}`));
});
});
}
}

View File

@@ -160,6 +160,11 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository
if (params.ownerId) {
conditions.push(`"ownerId" = $${paramIndex++}`);
values.push(params.ownerId);
} else {
// Public list: hide raw OSM imports until an admin reviews + promotes
// them. MANUAL rows + OSM_PROMOTED rows stay visible.
conditions.push(`"isPublic" = true`);
conditions.push(`"dataSource"::text IN ('MANUAL', 'OSM_PROMOTED')`);
}
if (params.query) {
conditions.push(`(name ILIKE $${paramIndex} OR "nameEn" ILIKE $${paramIndex} OR developer ILIKE $${paramIndex} OR province ILIKE $${paramIndex})`);
@@ -175,10 +180,14 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository
);
const total = Number(countResult[0].count);
// Sort by area DESC primarily — the public catalog now contains ~2k
// OSM_PROMOTED rows, many of which are small factory polygons. Putting
// the largest KCN first surfaces the meaningful entries; occupancy
// rate is a tiebreaker for curated rows where it's actually filled in.
const rows = await this.prisma.$queryRawUnsafe<RawPark[]>(
`SELECT *, ST_Y(location::geometry) as lat, ST_X(location::geometry) as lng
FROM "IndustrialPark" WHERE ${where}
ORDER BY "occupancyRate" DESC, "createdAt" DESC
ORDER BY "totalAreaHa" DESC NULLS LAST, "occupancyRate" DESC, "createdAt" DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
...values, limit, offset,
);

View File

@@ -6,18 +6,23 @@ import { CurrentUser, JwtAuthGuard, Roles, RolesGuard, type JwtPayload } from '
import { NotFoundException } from '@modules/shared';
import { CreateIndustrialParkCommand } from '../../application/commands/create-industrial-park/create-industrial-park.command';
import { DeleteIndustrialParkCommand } from '../../application/commands/delete-industrial-park/delete-industrial-park.command';
import { LockOsmParkCommand } from '../../application/commands/lock-osm-park/lock-osm-park.command';
import { PromoteOsmParkCommand } from '../../application/commands/promote-osm-park/promote-osm-park.command';
import { UpdateIndustrialParkCommand } from '../../application/commands/update-industrial-park/update-industrial-park.command';
import { AnalyzeIndustrialLocationQuery } from '../../application/queries/analyze-industrial-location/analyze-industrial-location.query';
import { CompareIndustrialParksQuery } from '../../application/queries/compare-industrial-parks/compare-industrial-parks.query';
import { EstimateIndustrialRentQuery } from '../../application/queries/estimate-industrial-rent/estimate-industrial-rent.query';
import { GetIndustrialParkQuery } from '../../application/queries/get-industrial-park/get-industrial-park.query';
import { GetIndustrialParksByBboxQuery } from '../../application/queries/get-industrial-parks-by-bbox/get-industrial-parks-by-bbox.query';
import { IndustrialMarketQuery } from '../../application/queries/industrial-market/industrial-market.query';
import { IndustrialParkStatsQuery } from '../../application/queries/industrial-park-stats/industrial-park-stats.query';
import { ListIndustrialParksQuery } from '../../application/queries/list-industrial-parks/list-industrial-parks.query';
import { ListOsmPendingQuery } from '../../application/queries/list-osm-pending/list-osm-pending.query';
import { AnalyzeIndustrialLocationDto } from '../dto/analyze-industrial-location.dto';
import { CompareIndustrialParksDto } from '../dto/compare-industrial-parks.dto';
import { CreateIndustrialParkDto } from '../dto/create-industrial-park.dto';
import { EstimateIndustrialRentDto } from '../dto/estimate-industrial-rent.dto';
import { IndustrialParksBboxDto } from '../dto/parks-bbox.dto';
import { SearchIndustrialParksDto } from '../dto/search-industrial-parks.dto';
import { UpdateIndustrialParkDto } from '../dto/update-industrial-park.dto';
@@ -50,6 +55,24 @@ export class IndustrialParksController {
);
}
@ApiOperation({
summary: 'KCN trong viewport bản đồ',
description:
'Trả về GeoJSON FeatureCollection của KCN nằm trong bbox. Zoom < 12 chỉ trả centroid Point, zoom >= 12 kèm MultiPolygon outline.',
})
@ApiResponse({ status: 200, description: 'GeoJSON FeatureCollection + meta' })
@Get('parks/by-bbox')
async parksByBbox(@Query() dto: IndustrialParksBboxDto) {
return this.queryBus.execute(
new GetIndustrialParksByBboxQuery(
{ south: dto.south, west: dto.west, north: dto.north, east: dto.east },
dto.zoom,
dto.includeOsmRaw ?? false,
dto.limit ?? 1000,
),
);
}
// ── Park Operator endpoints ───────────────────────────────────────
@ApiOperation({
@@ -261,4 +284,72 @@ export class IndustrialParksController {
);
return { success: true };
}
// ── OSM review & promote (admin only) ────────────────────────────────
@ApiOperation({
summary: 'Hàng đợi review OSM (admin)',
description:
'Liệt kê các KCN có dataSource=OSM (chưa được duyệt). Admin có thể promote → public hoặc lock để bảo vệ.',
})
@ApiResponse({ status: 200, description: 'Danh sách KCN đang chờ review' })
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Get('parks/osm/pending')
async listOsmPending(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('q') q?: string,
@Query('province') province?: string,
@Query('minAreaHa') minAreaHa?: string,
@Query('region') region?: string,
) {
return this.queryBus.execute(
new ListOsmPendingQuery(
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 50,
q,
province,
minAreaHa !== undefined ? Number(minAreaHa) : 50,
region,
),
);
}
@ApiOperation({
summary: 'Promote KCN từ OSM (admin)',
description:
'Chuyển dataSource OSM → OSM_PROMOTED và set isPublic=true. Có thể lock các field admin vừa edit.',
})
@ApiResponse({ status: 200, description: 'Đã promote' })
@ApiResponse({ status: 404, description: 'Không tìm thấy KCN' })
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Post('parks/:id/osm/promote')
async promoteOsm(
@Param('id') id: string,
@Body() body: { lockFields?: string[] },
) {
return this.commandBus.execute(
new PromoteOsmParkCommand(id, body.lockFields ?? []),
);
}
@ApiOperation({
summary: 'Lock/unlock OSM sync cho KCN (admin)',
description: 'Khi locked=true, sync cron sẽ bỏ qua row này hoàn toàn.',
})
@ApiResponse({ status: 200, description: 'Đã cập nhật trạng thái lock' })
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Post('parks/:id/osm/lock')
async lockOsm(
@Param('id') id: string,
@Body() body: { locked: boolean },
) {
return this.commandBus.execute(new LockOsmParkCommand(id, body.locked));
}
}

View File

@@ -0,0 +1,68 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsInt, IsNumber, Max, Min } from 'class-validator';
/**
* Query params for `GET /industrial/parks/by-bbox`.
*
* The bbox covers the user's current Mapbox viewport. `zoom` controls
* whether the response includes polygon boundaries (zoom >= 12) or just
* Point centroids.
*/
export class IndustrialParksBboxDto {
@ApiProperty({ example: 8.0, description: 'Southern (min) latitude of the viewport' })
@Type(() => Number)
@IsNumber()
@Min(-90)
@Max(90)
south!: number;
@ApiProperty({ example: 102.0, description: 'Western (min) longitude of the viewport' })
@Type(() => Number)
@IsNumber()
@Min(-180)
@Max(180)
west!: number;
@ApiProperty({ example: 23.5, description: 'Northern (max) latitude of the viewport' })
@Type(() => Number)
@IsNumber()
@Min(-90)
@Max(90)
north!: number;
@ApiProperty({ example: 110.0, description: 'Eastern (max) longitude of the viewport' })
@Type(() => Number)
@IsNumber()
@Min(-180)
@Max(180)
east!: number;
@ApiProperty({ example: 8, description: 'Mapbox zoom level (0-22)' })
@Type(() => Number)
@IsInt()
@Min(0)
@Max(22)
zoom!: number;
@ApiProperty({
required: false,
default: false,
description: 'Include raw OSM imports (admin only). Default: false.',
})
@Transform(({ value }) => value === 'true' || value === true)
@IsBoolean()
includeOsmRaw?: boolean = false;
@ApiProperty({
required: false,
default: 3000,
description:
'Max features to return. Default 3000 covers the entire promoted KCN catalog at country zoom; raise to 5000 if you also include raw OSM imports.',
})
@Type(() => Number)
@IsInt()
@Min(1)
@Max(5000)
limit?: number = 3000;
}

View File

@@ -146,12 +146,14 @@ describe('UpdateListingStatusCommand', () => {
'listing-1',
'ACTIVE',
'user-1',
'ADMIN',
'Đã xác minh thông tin',
);
expect(command.listingId).toBe('listing-1');
expect(command.newStatus).toBe('ACTIVE');
expect(command.userId).toBe('user-1');
expect(command.userRole).toBe('ADMIN');
expect(command.moderationNotes).toBe('Đã xác minh thông tin');
});

View File

@@ -52,7 +52,7 @@ describe('UpdateListingStatusHandler', () => {
const listing = createListing('listing-1', 'PENDING_REVIEW');
mockListingRepo.findById.mockResolvedValue(listing);
const command = new UpdateListingStatusCommand('listing-1', 'ACTIVE', 'admin-1');
const command = new UpdateListingStatusCommand('listing-1', 'ACTIVE', 'admin-1', 'ADMIN');
const result = await handler.execute(command);
expect(result.status).toBe('ACTIVE');
@@ -64,7 +64,7 @@ describe('UpdateListingStatusHandler', () => {
const listing = createListing('listing-1', 'PENDING_REVIEW');
mockListingRepo.findById.mockResolvedValue(listing);
const command = new UpdateListingStatusCommand('listing-1', 'REJECTED', 'admin-1', 'Vi phạm chính sách');
const command = new UpdateListingStatusCommand('listing-1', 'REJECTED', 'admin-1', 'ADMIN', 'Vi phạm chính sách');
const result = await handler.execute(command);
expect(result.status).toBe('REJECTED');
@@ -74,7 +74,7 @@ describe('UpdateListingStatusHandler', () => {
const listing = createListing('listing-1', 'ACTIVE');
mockListingRepo.findById.mockResolvedValue(listing);
const command = new UpdateListingStatusCommand('listing-1', 'SOLD', 'seller-1');
const command = new UpdateListingStatusCommand('listing-1', 'SOLD', 'seller-1', 'SELLER');
const result = await handler.execute(command);
expect(result.status).toBe('SOLD');
@@ -83,7 +83,7 @@ describe('UpdateListingStatusHandler', () => {
it('throws NotFoundException for non-existent listing', async () => {
mockListingRepo.findById.mockResolvedValue(null);
const command = new UpdateListingStatusCommand('nonexistent', 'ACTIVE', 'admin-1');
const command = new UpdateListingStatusCommand('nonexistent', 'ACTIVE', 'admin-1', 'ADMIN');
await expect(handler.execute(command)).rejects.toThrow('Listing');
});
@@ -92,8 +92,28 @@ describe('UpdateListingStatusHandler', () => {
const listing = createListing('listing-1', 'DRAFT');
mockListingRepo.findById.mockResolvedValue(listing);
const command = new UpdateListingStatusCommand('listing-1', 'SOLD', 'seller-1');
const command = new UpdateListingStatusCommand('listing-1', 'SOLD', 'seller-1', 'SELLER');
await expect(handler.execute(command)).rejects.toThrow(/trạng thái/);
});
it('rejects moderation transitions from non-admin users', async () => {
const listing = createListing('listing-1', 'PENDING_REVIEW');
mockListingRepo.findById.mockResolvedValue(listing);
const command = new UpdateListingStatusCommand('listing-1', 'ACTIVE', 'seller-1', 'SELLER');
await expect(handler.execute(command)).rejects.toThrow(/quản trị viên/);
expect(mockListingRepo.update).not.toHaveBeenCalled();
});
it('rejects status updates from non-owner users', async () => {
const listing = createListing('listing-1', 'ACTIVE');
mockListingRepo.findById.mockResolvedValue(listing);
const command = new UpdateListingStatusCommand('listing-1', 'SOLD', 'other-user', 'SELLER');
await expect(handler.execute(command)).rejects.toThrow(/người bán/);
expect(mockListingRepo.update).not.toHaveBeenCalled();
});
});

View File

@@ -5,6 +5,7 @@ export class UpdateListingStatusCommand {
public readonly listingId: string,
public readonly newStatus: ListingStatus,
public readonly userId: string,
public readonly userRole?: string,
public readonly moderationNotes?: string,
) {}
}

View File

@@ -1,6 +1,6 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, NotFoundException, CacheService, CachePrefix, LoggerService } from '@modules/shared';
import { DomainException, ForbiddenException, NotFoundException, CacheService, CachePrefix, LoggerService } from '@modules/shared';
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
import { ModerationService } from '../../../domain/services/moderation.service';
import { UpdateListingStatusCommand } from './update-listing-status.command';
@@ -22,6 +22,23 @@ export class UpdateListingStatusHandler implements ICommandHandler<UpdateListing
throw new NotFoundException('Listing', command.listingId);
}
const isAdmin = command.userRole === 'ADMIN';
const isOwner = listing.sellerId === command.userId;
const isAssignedAgent = listing.agentId !== null && listing.agentId === command.userId;
const isModerationTransition =
(listing.status === 'PENDING_REVIEW' && command.newStatus === 'ACTIVE') ||
command.newStatus === 'REJECTED';
if (isModerationTransition && !isAdmin) {
throw new ForbiddenException('Chỉ quản trị viên mới có thể duyệt hoặc từ chối tin đăng');
}
if (!isAdmin && !isOwner && !isAssignedAgent) {
throw new ForbiddenException(
'Chỉ người bán, môi giới được giao hoặc quản trị viên mới có thể cập nhật trạng thái tin đăng',
);
}
this.moderationService.applyStatusTransition(
listing,
command.newStatus,

View File

@@ -2,6 +2,7 @@ import { forwardRef, Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { MulterModule } from '@nestjs/platform-express';
import { AnalyticsModule } from '@modules/analytics';
import { PaymentsModule } from '@modules/payments';
import { FeatureListingThrottlerGuard } from '@modules/shared';
import { AdminFeatureListingHandler } from './application/commands/admin-feature-listing/admin-feature-listing.handler';
import { BulkUpdateListingsHandler } from './application/commands/bulk-update-listings/bulk-update-listings.handler';
@@ -68,6 +69,7 @@ const EventHandlers = [
imports: [
CqrsModule,
forwardRef(() => AnalyticsModule),
PaymentsModule, // for PAYMENT_INITIATOR (used by FeatureListingHandler)
MulterModule.register({
limits: { fileSize: 100 * 1024 * 1024 }, // 100 MB — per-type limits enforced by FileValidationPipe
}),

View File

@@ -387,7 +387,7 @@ export class ListingsController {
@CurrentUser() user: JwtPayload,
): Promise<{ status: string }> {
return this.commandBus.execute(
new UpdateListingStatusCommand(id, dto.status, user.sub, dto.moderationNotes),
new UpdateListingStatusCommand(id, dto.status, user.sub, user.role, dto.moderationNotes),
);
}

View File

@@ -31,6 +31,9 @@ import {
SEARCH_QUERY_DURATION,
GOODGO_WS_CONNECTED_CLIENTS,
GOODGO_WS_MESSAGES_TOTAL,
READ_MODEL_PROJECTOR_LAG_SECONDS,
READ_MODEL_REFRESH_DURATION_SECONDS,
READ_MODEL_RECONCILIATION_DRIFT_TOTAL,
WEB_VITALS_LCP,
WEB_VITALS_FCP,
WEB_VITALS_CLS,
@@ -111,6 +114,24 @@ import { HttpMetricsInterceptor } from './presentation/interceptors/http-metrics
labelNames: ['namespace', 'event', 'direction'],
}),
// ── Read-Model Metrics (RFC-003) ──
makeGaugeProvider({
name: READ_MODEL_PROJECTOR_LAG_SECONDS,
help: 'Projector replication lag in seconds, by read model',
labelNames: ['read_model'],
}),
makeHistogramProvider({
name: READ_MODEL_REFRESH_DURATION_SECONDS,
help: 'Materialized-view refresh duration in seconds',
labelNames: ['read_model'],
buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60],
}),
makeCounterProvider({
name: READ_MODEL_RECONCILIATION_DRIFT_TOTAL,
help: 'Drift events detected during read-model reconciliation',
labelNames: ['read_model', 'severity'],
}),
// ── Services & Interceptors ──
MetricsService,
HttpMetricsInterceptor,

View File

@@ -0,0 +1,8 @@
/** Manually trigger an OSM sync run from the admin UI. */
export class TriggerOsmSyncCommand {
constructor(
public readonly layer: string,
public readonly category?: string | null,
public readonly chunk?: string | null,
) {}
}

View File

@@ -0,0 +1,17 @@
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { OsmSyncService } from '../../../infrastructure/osm-sync.service';
import { TriggerOsmSyncCommand } from './trigger-sync.command';
@CommandHandler(TriggerOsmSyncCommand)
export class TriggerOsmSyncHandler implements ICommandHandler<TriggerOsmSyncCommand> {
constructor(private readonly osmSync: OsmSyncService) {}
async execute(cmd: TriggerOsmSyncCommand): Promise<{ runId: string; status: string }> {
return this.osmSync.run({
layer: cmd.layer,
category: cmd.category ?? null,
chunk: cmd.chunk ?? null,
wait: false,
});
}
}

View File

@@ -0,0 +1,127 @@
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { GeoLookupService, PrismaService } from '@modules/shared';
import { OsmCoverageSummaryQuery } from './coverage-summary.query';
export interface CoverageRow {
layer: string;
category: string | null;
total: number;
withGeometry?: number; // only meaningful for admin boundaries
promoted?: number;
raw?: number;
lastSyncedAt: Date | null;
}
export interface OsmCoverageSummary {
rows: CoverageRow[];
totals: {
administrativeUnits: number;
poiTotal: number;
industrialParks: number;
transportStations: number;
transportLines: number;
};
}
/**
* Single endpoint that powers the `/admin/osm` dashboard top-of-page
* "what's where" panel. Aggregates per-layer counts so we don't need 5
* separate API calls.
*/
@QueryHandler(OsmCoverageSummaryQuery)
export class OsmCoverageSummaryHandler
implements IQueryHandler<OsmCoverageSummaryQuery, OsmCoverageSummary>
{
constructor(
private readonly prisma: PrismaService,
private readonly geo: GeoLookupService,
) {}
async execute(): Promise<OsmCoverageSummary> {
const [adminCov, poiByCategory, parkTotal, transportPoiTotal, transportLineTotal] =
await Promise.all([
this.geo.coverage(),
this.prisma.$queryRawUnsafe<
{
category: string;
total: bigint;
promoted: bigint;
raw: bigint;
lastSyncedAt: Date | null;
}[]
>(
`SELECT category::text AS category,
COUNT(*)::bigint AS total,
SUM(CASE WHEN "dataSource" = 'OSM_PROMOTED' THEN 1 ELSE 0 END)::bigint AS promoted,
SUM(CASE WHEN "dataSource" = 'OSM' THEN 1 ELSE 0 END)::bigint AS raw,
MAX("lastSyncedAt") AS "lastSyncedAt"
FROM "Poi"
GROUP BY category`,
),
this.prisma.industrialPark.count(),
this.prisma.poi.count({
where: {
category: {
in: ['METRO_STATION', 'RAILWAY_STATION', 'BUS_STATION', 'AIRPORT'],
},
},
}),
this.prisma.transportLine.count(),
]);
const rows: CoverageRow[] = [];
rows.push(
{
layer: 'admin-boundaries',
category: 'province',
total: adminCov.provinces.total,
withGeometry: adminCov.provinces.withGeometry,
lastSyncedAt: adminCov.provinces.lastSyncedAt,
},
{
layer: 'admin-boundaries',
category: 'district',
total: adminCov.districts.total,
withGeometry: adminCov.districts.withGeometry,
lastSyncedAt: adminCov.districts.lastSyncedAt,
},
{
layer: 'admin-boundaries',
category: 'ward',
total: adminCov.wards.total,
withGeometry: adminCov.wards.withGeometry,
lastSyncedAt: adminCov.wards.lastSyncedAt,
},
);
for (const p of poiByCategory) {
rows.push({
layer: 'poi',
category: p.category,
total: Number(p.total),
promoted: Number(p.promoted),
raw: Number(p.raw),
lastSyncedAt: p.lastSyncedAt,
});
}
rows.push({
layer: 'industrial-parks',
category: null,
total: parkTotal,
lastSyncedAt: null,
});
return {
rows,
totals: {
administrativeUnits:
adminCov.provinces.withGeometry +
adminCov.districts.withGeometry +
adminCov.wards.withGeometry,
poiTotal: poiByCategory.reduce((sum, p) => sum + Number(p.total), 0),
industrialParks: parkTotal,
transportStations: transportPoiTotal,
transportLines: transportLineTotal,
},
};
}
}

View File

@@ -0,0 +1,3 @@
/** Aggregate coverage view across all OSM-managed tables for the
* admin dashboard. */
export class OsmCoverageSummaryQuery {}

View File

@@ -0,0 +1,22 @@
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { type OsmSyncRun, type OsmSyncStatus, type Prisma } from '@prisma/client';
import { PrismaService } from '@modules/shared';
import { ListOsmSyncRunsQuery } from './list-runs.query';
@QueryHandler(ListOsmSyncRunsQuery)
export class ListOsmSyncRunsHandler
implements IQueryHandler<ListOsmSyncRunsQuery, OsmSyncRun[]>
{
constructor(private readonly prisma: PrismaService) {}
async execute(q: ListOsmSyncRunsQuery): Promise<OsmSyncRun[]> {
const where: Prisma.OsmSyncRunWhereInput = {};
if (q.layer) where.layer = q.layer;
if (q.status) where.status = q.status as OsmSyncStatus;
return this.prisma.osmSyncRun.findMany({
where,
orderBy: { startedAt: 'desc' },
take: Math.min(Math.max(q.limit, 1), 200),
});
}
}

View File

@@ -0,0 +1,8 @@
/** List recent OSM sync runs for the admin dashboard. */
export class ListOsmSyncRunsQuery {
constructor(
public readonly layer?: string,
public readonly status?: string,
public readonly limit: number = 50,
) {}
}

View File

@@ -0,0 +1,95 @@
import { Injectable } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { LoggerService } from '@modules/shared';
import { OsmSyncService } from '../osm-sync.service';
/**
* Scheduled sync runner. Spreads layer refreshes across the week so we
* never hit Overpass with two heavy queries simultaneously and stay
* under the per-IP rate limit.
*
* Schedule (Asia/Ho_Chi_Minh):
* - Daily 02:00 → POI category rotation (one per day, 20-day cycle)
* - Mon 02:30 → admin-boundaries level=4 (provinces, light)
* - Wed 02:30 → admin-boundaries level=6 (districts, medium)
* - Sat 02:30 → admin-boundaries level=8 (wards, heavy)
* - 1st of month 03:00 → industrial-parks (existing flow, kept here so
* everything routes through one orchestrator)
*
* All routes respect `OSM_SYNC_ENABLED=true` to allow disabling in dev.
*/
@Injectable()
export class OsmSyncCronService {
private readonly POI_CATEGORIES = [
'SCHOOL_PRIMARY', 'SCHOOL_SECONDARY', 'UNIVERSITY',
'HOSPITAL', 'CLINIC', 'PHARMACY',
'MARKET', 'SUPERMARKET', 'MALL', 'CONVENIENCE',
'BANK', 'ATM',
'PARK',
'GAS_STATION', 'POLICE', 'POST_OFFICE',
'METRO_STATION', 'RAILWAY_STATION', 'BUS_STATION', 'AIRPORT',
];
constructor(
private readonly osmSync: OsmSyncService,
private readonly logger: LoggerService,
) {}
private isEnabled(): boolean {
return process.env['OSM_SYNC_ENABLED'] === 'true';
}
@Cron('0 2 * * *', { timeZone: 'Asia/Ho_Chi_Minh' })
async dailyPoiRotation(): Promise<void> {
if (!this.isEnabled()) return;
// Pick one category based on day-of-year so we cycle evenly.
const dayOfYear = Math.floor(
(Date.now() - new Date(new Date().getUTCFullYear(), 0, 0).getTime()) / 86_400_000,
);
const category = this.POI_CATEGORIES[dayOfYear % this.POI_CATEGORIES.length]!;
this.logger.log(`Daily POI rotation: ${category}`, 'OsmSyncCronService');
await this.osmSync.run({ layer: 'poi', category, wait: false });
}
@Cron('30 2 * * 1', { timeZone: 'Asia/Ho_Chi_Minh' }) // Monday
async weeklyProvinces(): Promise<void> {
if (!this.isEnabled()) return;
await this.osmSync.run({ layer: 'admin-boundaries', category: 'province', wait: false });
}
@Cron('30 2 * * 3', { timeZone: 'Asia/Ho_Chi_Minh' }) // Wednesday
async weeklyDistricts(): Promise<void> {
if (!this.isEnabled()) return;
await this.osmSync.run({ layer: 'admin-boundaries', category: 'district', wait: false });
}
@Cron('30 2 * * 6', { timeZone: 'Asia/Ho_Chi_Minh' }) // Saturday
async weeklyWards(): Promise<void> {
if (!this.isEnabled()) return;
await this.osmSync.run({ layer: 'admin-boundaries', category: 'ward', wait: false });
}
@Cron('0 3 1 * *', { timeZone: 'Asia/Ho_Chi_Minh' }) // 1st of month
async monthlyIndustrialParks(): Promise<void> {
if (!this.isEnabled()) return;
// KCN sync runs per chunk to spread load.
for (const chunk of ['north', 'northCentral', 'southCentral', 'south']) {
await this.osmSync.run({ layer: 'industrial-parks', chunk, wait: true });
}
}
/** Refresh proximity / density mat views every Sunday after the weekly
* ward sync had time to settle. Always runs (cheap, no Overpass). */
@Cron('0 4 * * 0', { timeZone: 'Asia/Ho_Chi_Minh' })
async weeklyRefreshViews(): Promise<void> {
try {
await this.osmSync.refreshMaterializedViews();
} catch (err) {
this.logger.error(
`Refresh views failed: ${err instanceof Error ? err.message : err}`,
err instanceof Error ? err.stack : undefined,
'OsmSyncCronService',
);
}
}
}

View File

@@ -0,0 +1,255 @@
import { spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import * as path from 'node:path';
import { Injectable } from '@nestjs/common';
import { OsmSyncStatus } from '@prisma/client';
import { LoggerService, PrismaService } from '@modules/shared';
/**
* Catalog of every sync layer / category we know about. The orchestrator
* uses this to validate trigger requests, populate the admin UI, and
* decide which scripts to run on the cron schedule.
*/
export interface OsmSyncLayerDef {
layer: string;
category?: string;
/** Path of the tsx script under repo root. */
scriptPath: string;
/** Extra CLI args appended after `--category=` etc. */
extraArgs?: string[];
/** Approx Overpass cost — used to spread cron schedule. */
weight: 'light' | 'medium' | 'heavy';
}
export const SYNC_LAYERS: OsmSyncLayerDef[] = [
{
layer: 'admin-boundaries',
category: 'province',
scriptPath: 'scripts/sync-osm-admin-boundaries.ts',
extraArgs: ['--level=4'],
weight: 'light',
},
{
layer: 'admin-boundaries',
category: 'district',
scriptPath: 'scripts/sync-osm-admin-boundaries.ts',
extraArgs: ['--level=6'],
weight: 'medium',
},
{
layer: 'admin-boundaries',
category: 'ward',
scriptPath: 'scripts/sync-osm-admin-boundaries.ts',
extraArgs: ['--level=8'],
weight: 'heavy',
},
// POI categories — each one its own row so the dashboard shows progress
// per category and the cron can rotate them across days.
...['SCHOOL_PRIMARY', 'SCHOOL_SECONDARY', 'UNIVERSITY',
'HOSPITAL', 'CLINIC', 'PHARMACY',
'MARKET', 'SUPERMARKET', 'MALL', 'CONVENIENCE',
'BANK', 'ATM',
'PARK',
'GAS_STATION', 'POLICE', 'POST_OFFICE',
'METRO_STATION', 'RAILWAY_STATION', 'BUS_STATION', 'AIRPORT',
].map<OsmSyncLayerDef>((cat) => ({
layer: 'poi',
category: cat,
scriptPath: 'scripts/sync-osm-poi.ts',
extraArgs: [`--category=${cat}`],
weight: cat === 'BANK' || cat === 'PHARMACY' || cat === 'CONVENIENCE' ? 'medium' : 'light',
})),
{
layer: 'industrial-parks',
scriptPath: 'scripts/sync-osm-industrial-parks.ts',
weight: 'heavy',
},
];
/**
* Spawns the right tsx script for a given (layer, category, chunk) and
* tracks the run in `OsmSyncRun`. Used both by the cron service and the
* admin "Sync now" button.
*/
@Injectable()
export class OsmSyncService {
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
/**
* Refresh the proximity / density materialized views. Called by the
* cron and from the admin "Refresh views" button. Runs concurrently
* (`CONCURRENTLY`) so reads aren't blocked.
*/
async refreshMaterializedViews(): Promise<void> {
this.logger.log('Refreshing materialized views', 'OsmSyncService');
await this.prisma.$executeRawUnsafe(
`REFRESH MATERIALIZED VIEW CONCURRENTLY "mv_park_nearest_poi"`,
);
await this.prisma.$executeRawUnsafe(
`REFRESH MATERIALIZED VIEW "mv_poi_density_by_province"`,
);
this.logger.log('Materialized views refreshed', 'OsmSyncService');
}
/** Look up a sync layer by its (layer, category) tuple. */
findLayer(layer: string, category?: string | null): OsmSyncLayerDef | undefined {
return SYNC_LAYERS.find(
(l) => l.layer === layer && (l.category ?? null) === (category ?? null),
);
}
list(): OsmSyncLayerDef[] {
return SYNC_LAYERS;
}
/**
* Run a sync layer (script invocation). Inserts a RUNNING `OsmSyncRun`,
* captures script stdout/stderr line-by-line into the logger, and
* updates the row to SUCCESS / PARTIAL / FAILED + row counts on exit.
*
* Returns the persisted `OsmSyncRun.id` immediately if `wait=false` so
* the admin UI can poll, or after the script exits when `wait=true`.
*/
async run(opts: {
layer: string;
category?: string | null;
chunk?: string | null;
wait?: boolean;
}): Promise<{ runId: string; status: OsmSyncStatus }> {
const def = this.findLayer(opts.layer, opts.category);
if (!def) {
throw new Error(`Unknown OSM sync layer: ${opts.layer}/${opts.category ?? '-'}`);
}
const args = [...(def.extraArgs ?? [])];
if (opts.chunk) args.push(`--chunk=${opts.chunk}`);
const queryHash = createHash('sha256')
.update(`${def.scriptPath} ${args.join(' ')}`)
.digest('hex')
.slice(0, 16);
const run = await this.prisma.osmSyncRun.create({
data: {
layer: opts.layer,
category: opts.category ?? null,
chunk: opts.chunk ?? null,
status: OsmSyncStatus.RUNNING,
overpassQueryHash: queryHash,
},
});
this.logger.log(
`OSM sync started run=${run.id} layer=${opts.layer} category=${opts.category ?? '-'} chunk=${opts.chunk ?? '-'}`,
'OsmSyncService',
);
const promise = this.spawnAndTrack(run.id, def, args);
if (opts.wait) {
const status = await promise;
return { runId: run.id, status };
}
void promise.catch((err) =>
this.logger.error(
`OSM sync ${run.id} background failure: ${err}`,
err instanceof Error ? err.stack : undefined,
'OsmSyncService',
),
);
return { runId: run.id, status: OsmSyncStatus.RUNNING };
}
private async spawnAndTrack(
runId: string,
def: OsmSyncLayerDef,
args: string[],
): Promise<OsmSyncStatus> {
return new Promise((resolve) => {
const repoRoot = path.resolve(__dirname, '../../../../../../..');
const child = spawn('pnpm', ['tsx', def.scriptPath, ...args], {
cwd: repoRoot,
env: {
...process.env,
NODE_OPTIONS: '-r dotenv/config',
DOTENV_CONFIG_PATH: '.env',
},
});
const stats = { added: 0, updated: 0, skipped: 0, locked: 0 };
const errors: string[] = [];
const parseLine = (line: string) => {
// Lines like: "inserted=12 updated=3 locked=1 skipped=0"
const m = line.match(/inserted=(\d+).*updated=(\d+).*locked=(\d+).*skipped=(\d+)/);
if (m) {
stats.added += Number(m[1]);
stats.updated += Number(m[2]);
stats.locked += Number(m[3]);
stats.skipped += Number(m[4]);
}
};
child.stdout?.on('data', (b) => {
for (const line of b.toString().split('\n')) {
if (!line.trim()) continue;
this.logger.log(`[${runId}] ${line.trim()}`, 'OsmSyncService');
parseLine(line);
}
});
child.stderr?.on('data', (b) => {
for (const line of b.toString().split('\n')) {
if (!line.trim()) continue;
this.logger.warn(`[${runId}] ${line.trim()}`, 'OsmSyncService');
if (errors.length < 20) errors.push(line.trim().slice(0, 500));
}
});
child.on('error', async (err) => {
await this.complete(runId, OsmSyncStatus.FAILED, stats, err.message);
resolve(OsmSyncStatus.FAILED);
});
child.on('exit', async (code) => {
const status =
code === 0
? errors.length > 0
? OsmSyncStatus.PARTIAL
: OsmSyncStatus.SUCCESS
: OsmSyncStatus.FAILED;
await this.complete(
runId,
status,
stats,
status === OsmSyncStatus.SUCCESS
? null
: `exit=${code}; ${errors.slice(0, 5).join(' | ')}`,
);
resolve(status);
});
});
}
private async complete(
runId: string,
status: OsmSyncStatus,
stats: { added: number; updated: number; skipped: number; locked: number },
errorMessage: string | null,
): Promise<void> {
await this.prisma.osmSyncRun.update({
where: { id: runId },
data: {
status,
finishedAt: new Date(),
rowsAdded: stats.added,
rowsUpdated: stats.updated,
rowsSkipped: stats.skipped,
rowsLocked: stats.locked,
errorMessage,
},
});
this.logger.log(
`OSM sync ${runId}${status}: added=${stats.added} updated=${stats.updated} skipped=${stats.skipped} locked=${stats.locked}`,
'OsmSyncService',
);
}
}

View File

@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { TriggerOsmSyncHandler } from './application/commands/trigger-sync/trigger-sync.handler';
import { OsmCoverageSummaryHandler } from './application/queries/coverage-summary/coverage-summary.handler';
import { ListOsmSyncRunsHandler } from './application/queries/list-runs/list-runs.handler';
import { OsmSyncCronService } from './infrastructure/cron/osm-sync-cron.service';
import { OsmSyncService } from './infrastructure/osm-sync.service';
import { OsmSyncController } from './presentation/controllers/osm-sync.controller';
const Handlers = [
TriggerOsmSyncHandler,
ListOsmSyncRunsHandler,
OsmCoverageSummaryHandler,
];
@Module({
imports: [CqrsModule],
controllers: [OsmSyncController],
providers: [OsmSyncService, OsmSyncCronService, ...Handlers],
exports: [OsmSyncService],
})
export class OsmSyncModule {}

View File

@@ -0,0 +1,74 @@
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { UserRole } from '@prisma/client';
import { JwtAuthGuard, Roles, RolesGuard } from '@modules/auth';
import { TriggerOsmSyncCommand } from '../../application/commands/trigger-sync/trigger-sync.command';
import { OsmCoverageSummaryQuery } from '../../application/queries/coverage-summary/coverage-summary.query';
import { ListOsmSyncRunsQuery } from '../../application/queries/list-runs/list-runs.query';
import { OsmSyncService } from '../../infrastructure/osm-sync.service';
import { TriggerSyncDto } from '../dto/trigger-sync.dto';
/**
* Admin-only endpoints powering the `/admin/osm` dashboard. Public users
* never hit this controller — guarded by JwtAuthGuard + RolesGuard(ADMIN).
*/
@ApiTags('osm-sync')
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Controller('admin/osm')
export class OsmSyncController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
private readonly osmSync: OsmSyncService,
) {}
@ApiOperation({ summary: 'List configured sync layers (catalog)' })
@Get('layers')
layers(): { layer: string; category?: string; weight: string }[] {
return this.osmSync.list().map((l) => ({
layer: l.layer,
category: l.category,
weight: l.weight,
}));
}
@ApiOperation({ summary: 'Coverage summary across all layers' })
@Get('coverage')
coverage() {
return this.queryBus.execute(new OsmCoverageSummaryQuery());
}
@ApiOperation({ summary: 'Recent sync runs (latest first)' })
@Get('runs')
runs(
@Query('layer') layer?: string,
@Query('status') status?: string,
@Query('limit') limit?: string,
) {
return this.queryBus.execute(
new ListOsmSyncRunsQuery(layer, status, limit ? Number(limit) : 50),
);
}
@ApiOperation({ summary: 'Trigger a sync run now (returns runId for polling)' })
@ApiResponse({ status: 201, description: 'Sync started' })
@Post('runs')
trigger(@Body() dto: TriggerSyncDto) {
return this.commandBus.execute(
new TriggerOsmSyncCommand(dto.layer, dto.category, dto.chunk),
);
}
@ApiOperation({
summary: 'Refresh proximity materialized views',
description: 'Recomputes mv_park_nearest_poi + mv_poi_density_by_province.',
})
@Post('refresh-views')
async refreshViews(): Promise<{ ok: true }> {
await this.osmSync.refreshMaterializedViews();
return { ok: true };
}
}

View File

@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
export class TriggerSyncDto {
@ApiProperty({ example: 'admin-boundaries' }) @IsString() layer!: string;
@ApiProperty({ required: false, example: 'province' }) @IsOptional() @IsString() category?: string;
@ApiProperty({ required: false, example: 'north' }) @IsOptional() @IsString() chunk?: string;
}

View File

@@ -25,6 +25,7 @@ import { PaymentGatewayFactory } from './infrastructure/services/payment-gateway
import { PAYMENT_GATEWAY_FACTORY } from './infrastructure/services/payment-gateway.interface';
import { VnpayService } from './infrastructure/services/vnpay.service';
import { ZalopayService } from './infrastructure/services/zalopay.service';
import { AdminPaymentsController } from './presentation/controllers/admin-payments.controller';
import { OrdersController } from './presentation/controllers/orders.controller';
import { PaymentsController } from './presentation/controllers/payments.controller';
@@ -47,7 +48,7 @@ const QueryHandlers = [
@Module({
imports: [CqrsModule],
controllers: [OrdersController, PaymentsController],
controllers: [AdminPaymentsController, OrdersController, PaymentsController],
providers: [
// Repositories
{ provide: ESCROW_REPOSITORY, useClass: PrismaEscrowRepository },

View File

@@ -0,0 +1,55 @@
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { PrismaService } from '@modules/shared';
import { PoiCoverageStatsQuery } from './coverage-stats.query';
export interface PoiCoverageRow {
category: string;
total: number;
promoted: number;
raw: number;
lastSyncedAt: Date | null;
}
@QueryHandler(PoiCoverageStatsQuery)
export class PoiCoverageStatsHandler
implements IQueryHandler<PoiCoverageStatsQuery, PoiCoverageRow[]>
{
constructor(private readonly prisma: PrismaService) {}
async execute(q: PoiCoverageStatsQuery): Promise<PoiCoverageRow[]> {
const provinceFilter = q.provinceCode ? `WHERE "provinceCode" = $1` : '';
const params = q.provinceCode ? [q.provinceCode] : [];
const rows = await this.prisma.$queryRawUnsafe<
{
category: string;
total: bigint;
promoted: bigint;
raw: bigint;
lastSyncedAt: Date | null;
}[]
>(
`
SELECT
category::text AS category,
COUNT(*)::bigint AS total,
SUM(CASE WHEN "dataSource" = 'OSM_PROMOTED' THEN 1 ELSE 0 END)::bigint AS promoted,
SUM(CASE WHEN "dataSource" = 'OSM' THEN 1 ELSE 0 END)::bigint AS raw,
MAX("lastSyncedAt") AS "lastSyncedAt"
FROM "Poi"
${provinceFilter}
GROUP BY category
ORDER BY total DESC
`,
...params,
);
return rows.map((r) => ({
category: r.category,
total: Number(r.total),
promoted: Number(r.promoted),
raw: Number(r.raw),
lastSyncedAt: r.lastSyncedAt,
}));
}
}

View File

@@ -0,0 +1,5 @@
/** Query: aggregate per-category counts so the admin /admin/osm dashboard
* can show "30k schools, 15k hospitals, …" without a join per row. */
export class PoiCoverageStatsQuery {
constructor(public readonly provinceCode?: string | null) {}
}

View File

@@ -0,0 +1,123 @@
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { LoggerService, PrismaService } from '@modules/shared';
import { FindNearbyPoiQuery } from './find-nearby-poi.query';
export interface NearbyPoi {
id: string;
name: string;
category: string;
/** Great-circle distance in metres from the requested centre. */
distanceM: number;
lat: number;
lng: number;
address: string | null;
}
export interface NearbyPoiResult {
/** Grouped by category for easy rendering as "tiện ích" chips. */
byCategory: Record<string, NearbyPoi[]>;
/** Flat list ordered by distance — used by the map overlay layer. */
all: NearbyPoi[];
meta: {
radiusMeters: number;
totalCount: number;
requestedCategories: string[] | null;
};
}
@QueryHandler(FindNearbyPoiQuery)
export class FindNearbyPoiHandler
implements IQueryHandler<FindNearbyPoiQuery, NearbyPoiResult>
{
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async execute(q: FindNearbyPoiQuery): Promise<NearbyPoiResult> {
const radius = Math.min(Math.max(q.radiusMeters, 50), 10_000);
const limitPerCat = Math.min(Math.max(q.limitPerCategory, 1), 50);
const cleanCats = (q.categories ?? [])
.map((c) => c.trim().toUpperCase())
.filter((c) => /^[A-Z_]+$/.test(c));
const categoryFilter = cleanCats.length
? `AND category::text IN (${cleanCats.map((c) => `'${c}'`).join(', ')})`
: '';
try {
// PostGIS `ST_DWithin` with `geography::` cast does the great-circle
// metres check. For each row we also compute the actual distance
// (cast back to geography) and rank within category.
const rows = await this.prisma.$queryRawUnsafe<
{
id: string;
name: string;
category: string;
address: string | null;
lat: number;
lng: number;
distance_m: number;
rank: number;
}[]
>(
`
SELECT id, name, category, address, lat, lng, distance_m, rank FROM (
SELECT
p.id, p.name, p.category::text AS category, p.address,
ST_Y(p.location::geometry) AS lat,
ST_X(p.location::geometry) AS lng,
ST_Distance(p.location::geography, c.center::geography) AS distance_m,
ROW_NUMBER() OVER (
PARTITION BY p.category
ORDER BY ST_Distance(p.location::geography, c.center::geography)
) AS rank
FROM "Poi" p,
(SELECT ST_SetSRID(ST_MakePoint($1, $2), 4326) AS center) c
WHERE p."isPublic" = true
AND p."dataSource"::text IN ('OSM', 'OSM_PROMOTED', 'MANUAL')
AND ST_DWithin(p.location::geography, c.center::geography, $3)
${categoryFilter}
) ranked
WHERE rank <= $4
ORDER BY distance_m
`,
q.center.lng,
q.center.lat,
radius,
limitPerCat,
);
const items: NearbyPoi[] = rows.map((r) => ({
id: r.id,
name: r.name,
category: r.category,
distanceM: Math.round(r.distance_m),
lat: r.lat,
lng: r.lng,
address: r.address,
}));
const byCategory: Record<string, NearbyPoi[]> = {};
for (const it of items) {
(byCategory[it.category] ??= []).push(it);
}
return {
byCategory,
all: items,
meta: {
radiusMeters: radius,
totalCount: items.length,
requestedCategories: cleanCats.length ? cleanCats : null,
},
};
} catch (err) {
this.logger.error(
`Find nearby POI failed: ${err instanceof Error ? err.message : err}`,
err instanceof Error ? err.stack : undefined,
this.constructor.name,
);
throw err;
}
}
}

View File

@@ -0,0 +1,13 @@
/**
* Query: find POI within a radius around a centre point. Drives the
* "tiện ích xung quanh" sidebar on listing / project / KCN detail pages
* and the search filter "trong vòng X mét từ trường".
*/
export class FindNearbyPoiQuery {
constructor(
public readonly center: { lng: number; lat: number },
public readonly radiusMeters: number,
public readonly categories: string[] | null,
public readonly limitPerCategory: number = 5,
) {}
}

View File

@@ -0,0 +1,99 @@
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import type { Feature, FeatureCollection } from 'geojson';
import { LoggerService, PrismaService } from '@modules/shared';
import { ListPoiByBboxQuery } from './list-poi-by-bbox.query';
interface BboxRow {
id: string;
name: string;
category: string;
province_code: string | null;
district_code: string | null;
point: string; // GeoJSON Point as text from ST_AsGeoJSON
}
export interface PoiGeoCollection extends FeatureCollection {
meta: {
count: number;
truncated: boolean;
categories: string[];
};
}
@QueryHandler(ListPoiByBboxQuery)
export class ListPoiByBboxHandler
implements IQueryHandler<ListPoiByBboxQuery, PoiGeoCollection>
{
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async execute(q: ListPoiByBboxQuery): Promise<PoiGeoCollection> {
const { south, west, north, east } = q.bbox;
const limit = Math.min(Math.max(q.limit, 1), 5000);
// Build optional category filter — Prisma can't safely interpolate enum
// arrays so we whitelist + inline.
const cleanCats = (q.categories ?? [])
.map((c) => c.trim().toUpperCase())
.filter((c) => /^[A-Z_]+$/.test(c));
const categoryFilter = cleanCats.length
? `AND category::text IN (${cleanCats.map((c) => `'${c}'`).join(', ')})`
: '';
try {
const rows = await this.prisma.$queryRawUnsafe<BboxRow[]>(
`
SELECT id, name, category::text AS category,
"provinceCode" AS province_code,
"districtCode" AS district_code,
ST_AsGeoJSON(location) AS point
FROM "Poi"
WHERE "isPublic" = true
AND "dataSource"::text IN ('OSM', 'OSM_PROMOTED', 'MANUAL')
AND location && ST_MakeEnvelope($1, $2, $3, $4, 4326)
${categoryFilter}
LIMIT ${limit + 1}
`,
west,
south,
east,
north,
);
const truncated = rows.length > limit;
const trimmed = truncated ? rows.slice(0, limit) : rows;
const features: Feature[] = trimmed.map((r) => ({
type: 'Feature',
id: r.id,
geometry: JSON.parse(r.point),
properties: {
id: r.id,
name: r.name,
category: r.category,
provinceCode: r.province_code,
districtCode: r.district_code,
},
}));
return {
type: 'FeatureCollection',
features,
meta: {
count: trimmed.length,
truncated,
categories: Array.from(new Set(trimmed.map((r) => r.category))),
},
};
} catch (err) {
this.logger.error(
`Poi bbox query failed: ${err instanceof Error ? err.message : err}`,
err instanceof Error ? err.stack : undefined,
this.constructor.name,
);
throw err;
}
}
}

View File

@@ -0,0 +1,12 @@
/**
* Query: list POI inside a Mapbox-style bounding box, filtered by category.
* Used by the public catalog map and the listing-detail "tiện ích xung
* quanh" chips.
*/
export class ListPoiByBboxQuery {
constructor(
public readonly bbox: { south: number; west: number; north: number; east: number },
public readonly categories: string[] | null,
public readonly limit: number = 1000,
) {}
}

View File

@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { PoiCoverageStatsHandler } from './application/queries/coverage-stats/coverage-stats.handler';
import { FindNearbyPoiHandler } from './application/queries/find-nearby-poi/find-nearby-poi.handler';
import { ListPoiByBboxHandler } from './application/queries/list-poi-by-bbox/list-poi-by-bbox.handler';
import { PoiController } from './presentation/controllers/poi.controller';
const QueryHandlers = [
ListPoiByBboxHandler,
FindNearbyPoiHandler,
PoiCoverageStatsHandler,
];
@Module({
imports: [CqrsModule],
controllers: [PoiController],
providers: [...QueryHandlers],
})
export class PoiModule {}

View File

@@ -0,0 +1,55 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { UserRole } from '@prisma/client';
import { JwtAuthGuard, Roles, RolesGuard } from '@modules/auth';
import { PoiCoverageStatsQuery } from '../../application/queries/coverage-stats/coverage-stats.query';
import { FindNearbyPoiQuery } from '../../application/queries/find-nearby-poi/find-nearby-poi.query';
import { ListPoiByBboxQuery } from '../../application/queries/list-poi-by-bbox/list-poi-by-bbox.query';
import { FindNearbyPoiDto } from '../dto/find-nearby-poi.dto';
import { ListPoiByBboxDto } from '../dto/list-poi-by-bbox.dto';
@ApiTags('poi')
@Controller('poi')
export class PoiController {
constructor(private readonly queryBus: QueryBus) {}
@ApiOperation({ summary: 'POI in viewport (GeoJSON FeatureCollection)' })
@ApiResponse({ status: 200, description: 'GeoJSON + meta' })
@Get('by-bbox')
async byBbox(@Query() dto: ListPoiByBboxDto) {
return this.queryBus.execute(
new ListPoiByBboxQuery(
{ south: dto.south, west: dto.west, north: dto.north, east: dto.east },
dto.categories ?? null,
dto.limit ?? 1000,
),
);
}
@ApiOperation({
summary: 'POI within radius around a point',
description:
'Drives "tiện ích xung quanh" sidebar. Returns up to N nearest POI per category.',
})
@Get('nearby')
async nearby(@Query() dto: FindNearbyPoiDto) {
return this.queryBus.execute(
new FindNearbyPoiQuery(
{ lng: dto.lng, lat: dto.lat },
dto.radius,
dto.categories ?? null,
dto.limitPerCategory ?? 5,
),
);
}
@ApiOperation({ summary: 'POI coverage stats per category (admin)' })
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Get('coverage')
async coverage(@Query('provinceCode') provinceCode?: string) {
return this.queryBus.execute(new PoiCoverageStatsQuery(provinceCode ?? null));
}
}

View File

@@ -0,0 +1,27 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsArray, IsInt, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
export class FindNearbyPoiDto {
@ApiProperty({ example: 10.762622 }) @Type(() => Number) @IsNumber() @Min(-90) @Max(90)
lat!: number;
@ApiProperty({ example: 106.660172 }) @Type(() => Number) @IsNumber() @Min(-180) @Max(180)
lng!: number;
@ApiProperty({ example: 1500, description: 'Radius in metres (50 - 10000)' })
@Type(() => Number) @IsInt() @Min(50) @Max(10_000)
radius!: number;
@ApiProperty({ required: false, isArray: true })
@IsOptional()
@IsArray()
@IsString({ each: true })
@Transform(({ value }) =>
typeof value === 'string' ? value.split(',').map((s) => s.trim()) : value,
)
categories?: string[];
@ApiProperty({ required: false, default: 5 })
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(50)
limitPerCategory?: number;
}

View File

@@ -0,0 +1,27 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsArray, IsInt, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
export class ListPoiByBboxDto {
@ApiProperty({ example: 10.5 }) @Type(() => Number) @IsNumber() @Min(-90) @Max(90)
south!: number;
@ApiProperty({ example: 106.5 }) @Type(() => Number) @IsNumber() @Min(-180) @Max(180)
west!: number;
@ApiProperty({ example: 11.0 }) @Type(() => Number) @IsNumber() @Min(-90) @Max(90)
north!: number;
@ApiProperty({ example: 107.0 }) @Type(() => Number) @IsNumber() @Min(-180) @Max(180)
east!: number;
@ApiProperty({ required: false, isArray: true, example: ['SCHOOL_PRIMARY', 'HOSPITAL'] })
@IsOptional()
@IsArray()
@IsString({ each: true })
@Transform(({ value }) =>
typeof value === 'string' ? value.split(',').map((s) => s.trim()) : value,
)
categories?: string[];
@ApiProperty({ required: false, default: 1000 })
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(5000)
limit?: number;
}

View File

@@ -0,0 +1,184 @@
import { Injectable } from '@nestjs/common';
import { LoggerService } from './logger.service';
import { PrismaService } from './prisma.service';
/**
* Result of a "where am I?" geo lookup. Each level may be null when the
* point lies outside any synced polygon (or when that level hasn't been
* synced yet — see PHASE_0 in the OSM rollout plan).
*/
export interface GeoLookupResult {
province: { code: string; name: string } | null;
district: { code: string; name: string } | null;
ward: { code: string; name: string } | null;
}
/**
* Centralised "lat/lng → administrative unit" resolver. Replaces the old
* `nearestProvince()` helper that walked a hard-coded centroid table —
* we now use real OSM-sourced polygons (PostGIS `ST_Contains`).
*
* Backed by the `vn_provinces` / `vn_districts` / `vn_wards` tables that
* `scripts/sync-osm-admin-boundaries.ts` populates. All three GIST-indexed
* geometry columns mean each lookup is O(log N).
*/
@Injectable()
export class GeoLookupService {
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
/**
* Resolve a point to the deepest administrative unit available. Returns
* partial results when the polygon hierarchy is incomplete (e.g. ward
* polygons not synced yet for that area).
*/
async lookup(lng: number, lat: number): Promise<GeoLookupResult> {
if (!this.isFiniteCoord(lng, lat)) {
return { province: null, district: null, ward: null };
}
// Province first — fastest GIST lookup, parents the other two.
const provinceRows = await this.prisma.$queryRawUnsafe<
{ code: string; name: string }[]
>(
`SELECT code, name FROM "vn_provinces"
WHERE geometry IS NOT NULL
AND ST_Contains(geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326))
LIMIT 1`,
lng,
lat,
);
const province = provinceRows[0] ?? null;
if (!province) return { province: null, district: null, ward: null };
// District scoped to the matched province for speed + correctness
// around shared borders.
const districtRows = await this.prisma.$queryRawUnsafe<
{ code: string; name: string }[]
>(
`SELECT code, name FROM "vn_districts"
WHERE "provinceCode" = $3
AND geometry IS NOT NULL
AND ST_Contains(geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326))
LIMIT 1`,
lng,
lat,
province.code,
);
const district = districtRows[0] ?? null;
if (!district) {
return { province, district: null, ward: null };
}
const wardRows = await this.prisma.$queryRawUnsafe<
{ code: string; name: string }[]
>(
`SELECT code, name FROM "vn_wards"
WHERE "districtCode" = $3
AND geometry IS NOT NULL
AND ST_Contains(geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326))
LIMIT 1`,
lng,
lat,
district.code,
);
const ward = wardRows[0] ?? null;
return { province, district, ward };
}
/** Convenience wrapper that returns just the province display name. */
async findProvinceName(lng: number, lat: number): Promise<string | null> {
const r = await this.lookup(lng, lat);
return r.province?.name ?? null;
}
/** True if any province polygon contains the point — i.e. point is in VN. */
async isInVietnam(lng: number, lat: number): Promise<boolean> {
if (!this.isFiniteCoord(lng, lat)) return false;
const rows = await this.prisma.$queryRawUnsafe<{ exists: boolean }[]>(
`SELECT EXISTS (
SELECT 1 FROM "vn_provinces"
WHERE geometry IS NOT NULL
AND ST_Contains(geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326))
) AS exists`,
lng,
lat,
);
return rows[0]?.exists ?? false;
}
/**
* Coverage report for the admin dashboard: how many polygons of each
* level we have, and when each was last refreshed. Cheap aggregate.
*/
async coverage(): Promise<{
provinces: { total: number; withGeometry: number; lastSyncedAt: Date | null };
districts: { total: number; withGeometry: number; lastSyncedAt: Date | null };
wards: { total: number; withGeometry: number; lastSyncedAt: Date | null };
}> {
const [p, d, w] = await Promise.all([
this.prisma.$queryRawUnsafe<
{ total: bigint; withGeometry: bigint; lastSyncedAt: Date | null }[]
>(
`SELECT COUNT(*)::bigint AS total,
COUNT(geometry)::bigint AS "withGeometry",
MAX("lastSyncedAt") AS "lastSyncedAt"
FROM "vn_provinces"`,
),
this.prisma.$queryRawUnsafe<
{ total: bigint; withGeometry: bigint; lastSyncedAt: Date | null }[]
>(
`SELECT COUNT(*)::bigint AS total,
COUNT(geometry)::bigint AS "withGeometry",
MAX("lastSyncedAt") AS "lastSyncedAt"
FROM "vn_districts"`,
),
this.prisma.$queryRawUnsafe<
{ total: bigint; withGeometry: bigint; lastSyncedAt: Date | null }[]
>(
`SELECT COUNT(*)::bigint AS total,
COUNT(geometry)::bigint AS "withGeometry",
MAX("lastSyncedAt") AS "lastSyncedAt"
FROM "vn_wards"`,
),
]);
return {
provinces: {
total: Number(p[0]?.total ?? 0n),
withGeometry: Number(p[0]?.withGeometry ?? 0n),
lastSyncedAt: p[0]?.lastSyncedAt ?? null,
},
districts: {
total: Number(d[0]?.total ?? 0n),
withGeometry: Number(d[0]?.withGeometry ?? 0n),
lastSyncedAt: d[0]?.lastSyncedAt ?? null,
},
wards: {
total: Number(w[0]?.total ?? 0n),
withGeometry: Number(w[0]?.withGeometry ?? 0n),
lastSyncedAt: w[0]?.lastSyncedAt ?? null,
},
};
}
private isFiniteCoord(lng: number, lat: number): boolean {
if (!Number.isFinite(lng) || !Number.isFinite(lat)) {
this.logger.warn(
`GeoLookupService: invalid coordinates lng=${lng} lat=${lat}`,
'GeoLookupService',
);
return false;
}
if (lng < -180 || lng > 180 || lat < -90 || lat > 90) {
this.logger.warn(
`GeoLookupService: out-of-range coordinates lng=${lng} lat=${lat}`,
'GeoLookupService',
);
return false;
}
return true;
}
}

View File

@@ -1,10 +1,83 @@
import { Injectable } from '@nestjs/common';
import { type ExecutionContext, Injectable } from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';
import { type Request } from 'express';
/**
* Extends ThrottlerGuard to extract real client IP behind reverse proxies
* (e.g., nginx, CloudFlare, AWS ALB) using X-Forwarded-For header.
* Phone numbers we use for demo / QA / E2E walkthroughs. Requests where
* the body / headers identify one of these accounts skip rate limiting
* entirely so testers (and automated UI tests) don't get blocked while
* they exercise login + flows repeatedly.
*
* Source-of-truth: the seed accounts in `prisma/seed.ts` and
* `prisma/seed-b2b-accounts.ts`. Prefix `+8487...` is the platform admin;
* the `+8490...` and `+8491...` ranges are the seed buyers / sellers /
* agents / developers / park-operators.
*/
const TEST_ACCOUNT_PHONES: ReadonlySet<string> = new Set([
'+84876677771', // admin
'+84900000002',
'+84900000003',
'+84900000004',
'+84900000005',
'+84900000006',
'+84900000007',
'+84900000008',
'+84912000001',
'+84912000002',
'+84912000003',
]);
const TEST_ACCOUNT_EMAILS: ReadonlySet<string> = new Set([
'hongochai10@icloud.com',
'agent.nguyen@goodgo.vn',
'agent.tran@goodgo.vn',
'agent.le.hong@goodgo.vn',
'buyer.le@gmail.com',
'buyer.hoang@gmail.com',
'seller.pham@gmail.com',
'seller.vo@gmail.com',
'cdt-vingroup@goodgo.vn',
'cdt-masterise@goodgo.vn',
'kcn-vsip@goodgo.vn',
]);
/**
* Optional override: a comma-separated list in `THROTTLER_BYPASS_PHONES`
* (or `..._EMAILS`) is added to the static set above. Useful for
* temporarily whitelisting a tester's number without redeploying.
*/
function envSet(name: string): Set<string> {
const raw = process.env[name];
if (!raw) return new Set();
return new Set(
raw
.split(',')
.map((s) => s.trim())
.filter(Boolean),
);
}
const ALL_BYPASS_PHONES = new Set([
...TEST_ACCOUNT_PHONES,
...envSet('THROTTLER_BYPASS_PHONES'),
]);
const ALL_BYPASS_EMAILS = new Set([
...TEST_ACCOUNT_EMAILS,
...envSet('THROTTLER_BYPASS_EMAILS'),
]);
interface AuthBody {
phone?: unknown;
email?: unknown;
identifier?: unknown;
}
/**
* Extends ThrottlerGuard to:
* 1. Extract real client IP behind reverse proxies via X-Forwarded-For.
* 2. Skip rate limiting entirely for demo / QA accounts (matched by the
* body's `phone` / `email` / `identifier`, or by the authenticated
* JWT subject when present), so testers don't lock themselves out.
*/
@Injectable()
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
@@ -14,4 +87,42 @@ export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
typeof forwarded === 'string' ? (forwarded.split(',')[0]?.trim() ?? '127.0.0.1') : req.ip;
return Promise.resolve(ip ?? '127.0.0.1');
}
protected override shouldSkip(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<
Request & { user?: { phone?: string; email?: string; sub?: string } }
>();
// 1. Authenticated request — JWT payload phone/email tested first.
if (req.user) {
const phone = typeof req.user.phone === 'string' ? req.user.phone : undefined;
const email = typeof req.user.email === 'string' ? req.user.email : undefined;
if (phone && ALL_BYPASS_PHONES.has(phone)) return Promise.resolve(true);
if (email && ALL_BYPASS_EMAILS.has(email.toLowerCase())) return Promise.resolve(true);
}
// 2. Login / register / password-reset bodies — extract from body fields
// that auth flows commonly use (`phone`, `email`, `identifier`).
const body = (req.body ?? {}) as AuthBody;
const phoneFromBody =
typeof body.phone === 'string' ? body.phone : undefined;
const identifierFromBody =
typeof body.identifier === 'string' ? body.identifier : undefined;
const emailFromBody =
typeof body.email === 'string' ? body.email : undefined;
if (phoneFromBody && ALL_BYPASS_PHONES.has(phoneFromBody)) {
return Promise.resolve(true);
}
if (identifierFromBody) {
if (ALL_BYPASS_PHONES.has(identifierFromBody)) return Promise.resolve(true);
if (ALL_BYPASS_EMAILS.has(identifierFromBody.toLowerCase()))
return Promise.resolve(true);
}
if (emailFromBody && ALL_BYPASS_EMAILS.has(emailFromBody.toLowerCase())) {
return Promise.resolve(true);
}
return Promise.resolve(false);
}
}

View File

@@ -9,6 +9,7 @@ export {
type ModelEncryptionFieldConfig,
} from './field-encryption.service';
export { createEncryptionExtension } from './encryption-middleware';
export { GeoLookupService, type GeoLookupResult } from './geo-lookup.service';
export { PrismaService } from './prisma.service';
export { RedisService } from './redis.service';
export { RedisIoAdapter } from './redis-io.adapter';

View File

@@ -8,6 +8,47 @@ const TOKEN_LENGTH = 32;
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
/**
* Routes that bootstrap a session (or accept beacons that can't carry a
* CSRF header) and are therefore exempt from the double-submit check.
* Matched against the request path with the API global prefix stripped.
*
* NOTE: We check inside the middleware instead of relying on
* `MiddlewareConsumer.exclude(...)` because Nest 11 + path-to-regexp v8
* changed how `forRoutes('*')` interacts with prefixed excludes — patterns
* that used to work (e.g. `'auth/login'`) silently no longer match.
*/
const EXEMPT_POST_PATHS = new Set<string>([
'/auth/login',
'/auth/register',
'/auth/refresh',
'/auth/logout',
'/auth/exchange-token',
'/auth/forgot-password',
'/auth/reset-password',
'/web-vitals',
]);
const EXEMPT_POST_PREFIXES: ReadonlyArray<string> = [
'/payments/callback/',
];
function stripApiPrefix(url: string): string {
// Only the path matters for matching — drop the query string.
const path = url.split('?')[0] ?? url;
if (path.startsWith('/api/v1')) {
return path.slice('/api/v1'.length) || '/';
}
return path;
}
function isExempt(method: string, url: string): boolean {
if (method !== 'POST') return false;
const path = stripApiPrefix(url);
if (EXEMPT_POST_PATHS.has(path)) return true;
return EXEMPT_POST_PREFIXES.some((prefix) => path.startsWith(prefix));
}
@Injectable()
export class CsrfMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction): void {
@@ -17,6 +58,13 @@ export class CsrfMiddleware implements NestMiddleware {
return next();
}
// Bootstrap + sendBeacon endpoints — never check, but still plant the
// cookie so the next request from the same client can pass validation.
if (isExempt(req.method, req.originalUrl ?? req.url)) {
this.ensureCsrfCookie(req, res);
return next();
}
// State-changing methods: validate the double-submit token
const cookieToken = req.cookies?.[CSRF_COOKIE] as string | undefined;
const headerToken = req.headers[CSRF_HEADER] as string | undefined;

View File

@@ -18,6 +18,7 @@ import {
import { EventBusService } from './infrastructure/event-bus.service';
import { FieldEncryptionService } from './infrastructure/field-encryption.service';
import { GlobalExceptionFilter } from './infrastructure/filters/global-exception.filter';
import { GeoLookupService } from './infrastructure/geo-lookup.service';
import { DeprecationInterceptor, VersionInterceptor } from './infrastructure/interceptors';
import { LoggerService } from './infrastructure/logger.service';
import { CorrelationIdMiddleware } from './infrastructure/middleware/correlation-id.middleware';
@@ -43,6 +44,7 @@ import { TypesenseClientService } from './infrastructure/typesense-client.servic
RedisService,
CacheService,
EventBusService,
GeoLookupService,
// RFC-004 Phase 0 (GOO-172) — see import comment above.
// { provide: EVENT_BUS, useClass: RedisStreamsEventBus },
// OutboxService,
@@ -78,7 +80,17 @@ import { TypesenseClientService } from './infrastructure/typesense-client.servic
useClass: DeprecationInterceptor,
},
],
exports: [PrismaService, RedisService, CacheService, LoggerService, EventBusService, FieldEncryptionService, TypesenseClientService, PrometheusModule],
exports: [
PrismaService,
RedisService,
CacheService,
LoggerService,
EventBusService,
FieldEncryptionService,
GeoLookupService,
TypesenseClientService,
PrometheusModule,
],
})
export class SharedModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
@@ -90,14 +102,29 @@ export class SharedModule implements NestModule {
consumer
.apply(CsrfMiddleware)
.exclude(
{ path: 'payments/callback/(.*)', method: RequestMethod.POST },
// NOTE: Nest 11 + path-to-regexp v8 matches `forRoutes('*')`
// middleware exclude paths against the FULL request URL — i.e.
// including the global prefix `api/v1`. Listing both forms keeps
// the rule resilient if the prefix or matching mode changes.
{ path: 'api/v1/payments/callback/*path', method: RequestMethod.POST },
{ path: 'api/v1/auth/login', method: RequestMethod.POST },
{ path: 'api/v1/auth/register', method: RequestMethod.POST },
{ path: 'api/v1/auth/refresh', method: RequestMethod.POST },
{ path: 'api/v1/auth/exchange-token', method: RequestMethod.POST },
{ path: 'api/v1/auth/logout', method: RequestMethod.POST },
{ path: 'api/v1/auth/forgot-password', method: RequestMethod.POST },
{ path: 'api/v1/auth/reset-password', method: RequestMethod.POST },
{ path: 'api/v1/web-vitals', method: RequestMethod.POST }, // sendBeacon cannot send CSRF headers
// Legacy controller-relative forms (kept for older path-matching modes).
{ path: 'auth/login', method: RequestMethod.POST },
{ path: 'auth/register', method: RequestMethod.POST },
{ path: 'auth/refresh', method: RequestMethod.POST },
{ path: 'auth/exchange-token', method: RequestMethod.POST },
{ path: 'auth/logout', method: RequestMethod.POST },
{ path: 'api/v1/web-vitals', method: RequestMethod.POST }, // sendBeacon cannot send CSRF headers
{ path: 'web-vitals', method: RequestMethod.POST }, // middleware exclude uses controller-relative path
{ path: 'auth/forgot-password', method: RequestMethod.POST },
{ path: 'auth/reset-password', method: RequestMethod.POST },
{ path: 'web-vitals', method: RequestMethod.POST },
{ path: 'payments/callback/*path', method: RequestMethod.POST },
)
.forRoutes('*');
}

View File

@@ -23,7 +23,10 @@ COPY --from=deps /app/.npmrc ./
COPY --from=deps /app/node_modules ./node_modules
COPY tsconfig.base.json ./
COPY apps/web/ apps/web/
RUN cd apps/web && npx next build
# Hoisted layout: `next` binary is in /app/node_modules/.bin/next.
# Neither `npx next` nor `pnpm run build` resolve it from apps/web cwd
# because that subtree has no node_modules. Call the binary directly.
RUN cd apps/web && /app/node_modules/.bin/next build
# ---- Production ----
FROM node:22-slim AS production

View File

@@ -90,6 +90,11 @@ describe('middleware authentication guard', () => {
expect(mockRedirectFn).not.toHaveBeenCalled();
});
it('allows unauthenticated user to reach /pricing', () => {
middleware(makeRequest('/pricing', false));
expect(mockRedirectFn).not.toHaveBeenCalled();
});
it('allows unauthenticated user to reach /login', () => {
middleware(makeRequest('/login', false));
expect(mockRedirectFn).not.toHaveBeenCalled();

View File

@@ -41,8 +41,11 @@ const MODULE_LABELS: Record<string, string> = {
moderation: 'Kiểm duyệt',
};
function SeverityPill({ severity }: { severity: AuditLogItem['severity'] }) {
const cfg = SEVERITY_CONFIG[severity];
function SeverityPill({ severity }: { severity: AuditLogItem['severity'] | undefined }) {
// The backend doesn't always populate `severity` (only the moderation
// audit log enriches it). Fall back to `info` so the pill renders rather
// than crashing the whole page when an entry omits the field.
const cfg = SEVERITY_CONFIG[severity ?? 'info'] ?? SEVERITY_CONFIG.info;
return <Signal direction={cfg.dir} label={cfg.label} />;
}

View File

@@ -0,0 +1,557 @@
'use client';
import {
CheckCircle,
Lock,
LockOpen,
RefreshCw,
ChevronLeft,
ChevronRight,
ExternalLink,
X,
Search,
AlertTriangle,
} from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Link } from '@/i18n/navigation';
import {
industrialApi,
REGION_LABELS,
type OsmPendingItem,
type OsmPendingResult,
type VietnamRegion,
} from '@/lib/khu-cong-nghiep-api';
/**
* Admin OSM review queue. Lists parks with `dataSource = 'OSM'` (raw imports
* from the monthly Overpass sync). Admins decide what to do with each row:
*
* - Promote → flips `dataSource` to `OSM_PROMOTED` and `isPublic = true`,
* so the row shows up in the public catalog. Optionally lock specific
* fields so the next sync run won't overwrite them.
* - Lock / Unlock → toggles `osmLocked`. When locked, the row is skipped
* entirely by the sync cron.
*
* Fields that admins commonly want to lock after edits: `name`, `developer`,
* `description`, `targetIndustries`. We surface these as quick-pick checkboxes
* in the promote dialog, plus a free-text fallback for anything else.
*/
const PAGE_SIZE = 50;
/** Buckets for the "Diện tích tối thiểu" filter. 50 ha is the default
* because most "real" KCN start there — anything below tends to be a
* single factory or warehouse mistagged as `landuse=industrial`. */
const MIN_AREA_OPTIONS: { value: number; label: string }[] = [
{ value: 0, label: 'Tất cả' },
{ value: 5, label: '≥ 5 ha' },
{ value: 50, label: '≥ 50 ha (KCN nhỏ)' },
{ value: 200, label: '≥ 200 ha (KCN lớn)' },
{ value: 500, label: '≥ 500 ha (KCN trọng điểm)' },
];
const QUICK_LOCK_FIELDS: { key: string; label: string }[] = [
{ key: 'name', label: 'Tên KCN' },
{ key: 'developer', label: 'Chủ đầu tư' },
{ key: 'description', label: 'Mô tả' },
{ key: 'targetIndustries', label: 'Ngành mục tiêu' },
{ key: 'totalAreaHa', label: 'Diện tích' },
{ key: 'status', label: 'Trạng thái' },
];
function formatTags(tags: Record<string, string> | null): string {
if (!tags) return '—';
// Surface the most useful keys first, then anything else, capped to keep
// the cell readable. Tag values are user-generated on OSM so we trim hard.
const priorityKeys = ['name', 'name:vi', 'name:en', 'operator', 'website'];
const ordered = [
...priorityKeys.filter((k) => k in tags),
...Object.keys(tags).filter((k) => !priorityKeys.includes(k)),
];
return ordered
.slice(0, 4)
.map((k) => `${k}=${String(tags[k]).slice(0, 30)}`)
.join(', ');
}
export default function AdminOsmReviewPage() {
const [result, setResult] = useState<OsmPendingResult | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [page, setPage] = useState(1);
// Filters
const [searchInput, setSearchInput] = useState('');
const [search, setSearch] = useState('');
const [provinceFilter, setProvinceFilter] = useState('');
const [minAreaHa, setMinAreaHa] = useState<number>(50);
const [region, setRegion] = useState<VietnamRegion | ''>('');
// Promote dialog state
const [promoteTarget, setPromoteTarget] = useState<OsmPendingItem | null>(null);
const [lockFields, setLockFields] = useState<Set<string>>(new Set());
const [extraField, setExtraField] = useState('');
const [actionLoading, setActionLoading] = useState(false);
const fetchQueue = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await industrialApi.listOsmPending({
page,
limit: PAGE_SIZE,
q: search || undefined,
province: provinceFilter || undefined,
minAreaHa,
region: region || undefined,
});
setResult(data);
} catch (e) {
setError(e instanceof Error ? e.message : 'Không thể tải hàng đợi OSM');
} finally {
setLoading(false);
}
}, [page, search, provinceFilter, minAreaHa, region]);
useEffect(() => {
fetchQueue();
}, [fetchQueue]);
const submitSearch = (e: React.FormEvent) => {
e.preventDefault();
setPage(1);
setSearch(searchInput.trim());
};
const handleToggleLock = async (item: OsmPendingItem) => {
setActionError(null);
try {
await industrialApi.lockOsm(item.id, !item.osmLocked);
fetchQueue();
} catch (e) {
setActionError(e instanceof Error ? e.message : 'Không thể cập nhật trạng thái lock');
}
};
const openPromoteDialog = (item: OsmPendingItem) => {
setPromoteTarget(item);
// Default: lock the name (so the next OSM sync doesn't rename it back to
// whatever Overpass has). Admins can uncheck if they want OSM to win.
setLockFields(new Set(['name']));
setExtraField('');
setActionError(null);
};
const closePromoteDialog = () => {
setPromoteTarget(null);
setLockFields(new Set());
setExtraField('');
};
const handlePromote = async () => {
if (!promoteTarget) return;
setActionLoading(true);
setActionError(null);
try {
const fields = Array.from(lockFields);
const extras = extraField
.split(',')
.map((s) => s.trim())
.filter(Boolean);
await industrialApi.promoteOsm(promoteTarget.id, [...fields, ...extras]);
closePromoteDialog();
fetchQueue();
} catch (e) {
setActionError(e instanceof Error ? e.message : 'Promote thất bại. Vui lòng thử lại.');
} finally {
setActionLoading(false);
}
};
const toggleLockField = (key: string) => {
setLockFields((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
return (
<div className="flex flex-col gap-4">
{actionError && (
<div className="flex items-center justify-between rounded-md border border-destructive/50 bg-destructive/10 px-4 py-2 text-sm text-destructive">
<span>{actionError}</span>
<button onClick={() => setActionError(null)} className="ml-2" aria-label="Đóng">
<X className="h-4 w-4" />
</button>
</div>
)}
{/* Header */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-heading-md font-semibold tracking-tight">Review KCN từ OpenStreetMap</h1>
<p className="text-sm text-foreground-muted">
Xét duyệt các KCN nhập từ OSM (chưa public). Promote public catalog hoặc lock đ giữ nguyên dữ liệu.
</p>
</div>
<Button variant="outline" size="sm" onClick={fetchQueue} disabled={loading}>
<RefreshCw className={`mr-1.5 h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
Làm mới
</Button>
</div>
{/* Filters */}
<Card className="shadow-elevation-1">
<CardContent className="p-4">
<form className="flex flex-wrap items-end gap-3" onSubmit={submitSearch}>
<div className="flex-1 min-w-[200px]">
<label className="mb-1 block text-xs text-foreground-dim">Tìm kiếm</label>
<Input
placeholder="Tên KCN, chủ đầu tư..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="h-8 text-sm"
/>
</div>
<div className="w-40">
<label className="mb-1 block text-xs text-foreground-dim">
Diện tích tối thiểu
</label>
<select
value={minAreaHa}
onChange={(e) => {
setPage(1);
setMinAreaHa(Number(e.target.value));
}}
className="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
>
{MIN_AREA_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div className="w-32">
<label className="mb-1 block text-xs text-foreground-dim">Vùng miền</label>
<select
value={region}
onChange={(e) => {
setPage(1);
setRegion((e.target.value as VietnamRegion) || '');
}}
className="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
>
<option value="">Tất cả</option>
{(Object.keys(REGION_LABELS) as VietnamRegion[]).map((r) => (
<option key={r} value={r}>
{REGION_LABELS[r]}
</option>
))}
</select>
</div>
<div className="w-40">
<label className="mb-1 block text-xs text-foreground-dim">Tỉnh / TP</label>
<Input
placeholder="Bắc Ninh, Đồng Nai..."
value={provinceFilter}
onChange={(e) => {
setPage(1);
setProvinceFilter(e.target.value);
}}
className="h-8 text-sm"
/>
</div>
<Button type="submit" size="sm">
<Search className="mr-1.5 h-3.5 w-3.5" />
Tìm
</Button>
{(search || provinceFilter || region || minAreaHa !== 50) && (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setSearchInput('');
setSearch('');
setProvinceFilter('');
setRegion('');
setMinAreaHa(50);
setPage(1);
}}
>
<X className="mr-1 h-3 w-3" />
Đt lại
</Button>
)}
</form>
</CardContent>
</Card>
{/* Table */}
<Card className="shadow-elevation-1 overflow-hidden">
<CardContent className="p-0">
{loading ? (
<div className="flex h-48 items-center justify-center">
<RefreshCw className="h-5 w-5 animate-spin text-foreground-muted" />
</div>
) : error ? (
<div className="flex h-48 flex-col items-center justify-center gap-2">
<p className="text-sm text-destructive">{error}</p>
<Button variant="outline" size="sm" onClick={fetchQueue}>
Thử lại
</Button>
</div>
) : !result || result.data.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center gap-2">
<CheckCircle className="h-8 w-8 text-signal-up" />
<p className="text-sm text-foreground-muted">Không KCN nào trong hàng đi</p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader className="sticky top-0 z-sticky-header bg-background-elevated">
<TableRow className="border-b border-border-strong">
<TableHead className="text-heading-xs uppercase text-foreground-muted">
Tên KCN
</TableHead>
<TableHead className="hidden sm:table-cell text-heading-xs uppercase text-foreground-muted">
Tỉnh
</TableHead>
<TableHead className="hidden md:table-cell text-heading-xs uppercase text-foreground-muted text-right">
Diện tích (ha)
</TableHead>
<TableHead className="hidden lg:table-cell text-heading-xs uppercase text-foreground-muted">
OSM
</TableHead>
<TableHead className="hidden xl:table-cell text-heading-xs uppercase text-foreground-muted">
Tags
</TableHead>
<TableHead className="text-heading-xs uppercase text-foreground-muted">
Trạng thái
</TableHead>
<TableHead className="text-right text-heading-xs uppercase text-foreground-muted">
Hành đng
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.data.map((item) => (
<TableRow
key={item.id}
className="h-row-compact border-b border-border hover:bg-background-surface transition-colors"
>
<TableCell>
<div className="font-medium max-w-[280px] truncate text-sm">
{item.name}
</div>
{item.nameEn && (
<div className="text-xs text-foreground-dim max-w-[280px] truncate">
{item.nameEn}
</div>
)}
</TableCell>
<TableCell className="hidden sm:table-cell text-sm text-foreground-muted">
{item.province}
</TableCell>
<TableCell className="hidden md:table-cell font-mono text-data-sm tabular-nums text-right">
{item.totalAreaHa
? new Intl.NumberFormat('vi-VN', {
maximumFractionDigits: 1,
}).format(item.totalAreaHa)
: '—'}
</TableCell>
<TableCell className="hidden lg:table-cell font-mono text-data-sm">
<div className="flex items-center gap-1 text-foreground-dim">
{item.osmType?.toLowerCase() ?? '—'}/{item.osmId}
{item.latitude != null && item.longitude != null && (
<a
href={`https://www.openstreetmap.org/${(item.osmType ?? 'way').toLowerCase()}/${item.osmId}`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground"
aria-label="Mở trên openstreetmap.org"
>
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
</TableCell>
<TableCell className="hidden xl:table-cell text-xs text-foreground-dim max-w-[260px] truncate">
{formatTags(item.osmTags)}
</TableCell>
<TableCell>
{item.osmLocked ? (
<span className="inline-flex items-center gap-1 rounded-pill bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 ring-1 ring-inset ring-amber-200">
<Lock className="h-3 w-3" />
Locked
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-pill bg-background-surface px-2 py-0.5 text-xs font-medium text-foreground-muted ring-1 ring-inset ring-border">
Pending
</span>
)}
</TableCell>
<TableCell>
<div className="flex justify-end gap-1">
{item.latitude != null && item.longitude != null && (
<Link
href={`/khu-cong-nghiep/${item.slug}` as never}
target="_blank"
className="rounded p-1 text-foreground-muted hover:bg-background-surface transition-colors"
aria-label={`Xem KCN: ${item.name}`}
title="Mở trang chi tiết"
>
<ExternalLink className="h-4 w-4" />
</Link>
)}
<button
title={item.osmLocked ? 'Bỏ khóa OSM sync' : 'Khóa OSM sync'}
onClick={() => handleToggleLock(item)}
className="rounded p-1 text-foreground-muted hover:bg-background-surface transition-colors"
aria-label={
item.osmLocked ? `Bỏ khóa: ${item.name}` : `Khóa: ${item.name}`
}
>
{item.osmLocked ? (
<LockOpen className="h-4 w-4" />
) : (
<Lock className="h-4 w-4" />
)}
</button>
<button
title="Promote → public"
onClick={() => openPromoteDialog(item)}
className="rounded p-1 text-signal-up hover:bg-signal-up/10 transition-colors"
aria-label={`Promote KCN: ${item.name}`}
>
<CheckCircle className="h-4 w-4" />
</button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{result.totalPages > 1 && (
<div className="flex items-center justify-between border-t border-border px-4 py-2.5">
<span className="font-mono text-data-sm text-foreground-muted">
Trang {result.page}/{result.totalPages} · {result.total} KCN
</span>
<div className="flex gap-1">
<Button
variant="outline"
size="icon"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
disabled={page >= result.totalPages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
)}
</CardContent>
</Card>
{/* Promote dialog */}
<Dialog
open={!!promoteTarget}
onOpenChange={(open) => {
if (!open) closePromoteDialog();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Promote KCN từ OSM</DialogTitle>
<DialogDescription>
<span className="flex items-start gap-2 text-sm">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
Sắp promote: <strong>{promoteTarget?.name}</strong>. KCN sẽ chuyển sang trạng thái
public (OSM_PROMOTED). Chọn các trường muốn khóa đ bảo vệ chúng khỏi OSM sync sau
này.
</span>
</span>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div>
<p className="mb-2 text-xs font-medium text-foreground-muted">Khóa các trường:</p>
<div className="grid grid-cols-2 gap-2">
{QUICK_LOCK_FIELDS.map(({ key, label }) => (
<label
key={key}
className="flex items-center gap-2 rounded-md border border-border px-2 py-1.5 text-sm cursor-pointer hover:bg-background-surface"
>
<input
type="checkbox"
checked={lockFields.has(key)}
onChange={() => toggleLockField(key)}
className="rounded border-border"
/>
{label}
</label>
))}
</div>
</div>
<div>
<p className="mb-1 text-xs font-medium text-foreground-muted">
Trường tùy chỉnh (cách nhau bởi dấu phẩy)
</p>
<Input
placeholder="vd: occupancyRate, leasableAreaHa"
value={extraField}
onChange={(e) => setExtraField(e.target.value)}
className="h-8 text-sm"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={closePromoteDialog} disabled={actionLoading}>
Hủy
</Button>
<Button onClick={handlePromote} disabled={actionLoading}>
{actionLoading ? 'Đang xử lý...' : 'Promote'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,321 @@
'use client';
import {
AlertTriangle,
CheckCircle,
Clock,
Layers,
MapPin,
PlayCircle,
RefreshCw,
Train,
XCircle,
} from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
osmSyncApi,
type OsmCoverageSummary,
type OsmSyncLayer,
type OsmSyncRun,
} from '@/lib/osm-sync-api';
const STATUS_STYLES: Record<OsmSyncRun['status'], string> = {
RUNNING: 'bg-blue-100 text-blue-800 ring-blue-200',
SUCCESS: 'bg-emerald-100 text-emerald-800 ring-emerald-200',
PARTIAL: 'bg-amber-100 text-amber-800 ring-amber-200',
FAILED: 'bg-red-100 text-red-800 ring-red-200',
};
const STATUS_ICONS: Record<OsmSyncRun['status'], React.ReactNode> = {
RUNNING: <RefreshCw className="h-3 w-3 animate-spin" />,
SUCCESS: <CheckCircle className="h-3 w-3" />,
PARTIAL: <AlertTriangle className="h-3 w-3" />,
FAILED: <XCircle className="h-3 w-3" />,
};
function formatRelative(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso);
const diff = Date.now() - d.getTime();
if (diff < 60_000) return 'vừa xong';
if (diff < 3_600_000) return `${Math.round(diff / 60_000)} phút trước`;
if (diff < 86_400_000) return `${Math.round(diff / 3_600_000)} giờ trước`;
return `${Math.round(diff / 86_400_000)} ngày trước`;
}
function formatDuration(start: string, end: string | null): string {
const startMs = new Date(start).getTime();
const endMs = end ? new Date(end).getTime() : Date.now();
const sec = Math.round((endMs - startMs) / 1000);
if (sec < 60) return `${sec}s`;
if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`;
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`;
}
export default function AdminOsmDashboardPage() {
const [coverage, setCoverage] = useState<OsmCoverageSummary | null>(null);
const [runs, setRuns] = useState<OsmSyncRun[]>([]);
const [layers, setLayers] = useState<OsmSyncLayer[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [triggering, setTriggering] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [cov, rs, ls] = await Promise.all([
osmSyncApi.coverage(),
osmSyncApi.runs({ limit: 30 }),
osmSyncApi.layers(),
]);
setCoverage(cov);
setRuns(rs);
setLayers(ls);
} catch (e) {
setError(e instanceof Error ? e.message : 'Lỗi tải dashboard');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
const int = setInterval(refresh, 15_000); // poll while RUNNING runs visible
return () => clearInterval(int);
}, [refresh]);
const trigger = async (layer: string, category?: string) => {
const key = `${layer}/${category ?? '-'}`;
setTriggering(key);
try {
await osmSyncApi.trigger({ layer, category });
await refresh();
} catch (e) {
setError(e instanceof Error ? e.message : 'Trigger fail');
} finally {
setTriggering(null);
}
};
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">OSM Sync Dashboard</h1>
<p className="text-sm text-muted-foreground">
Đng bộ OpenStreetMap Goodgo: ranh giới hành chính, POI, KCN, giao thông.
</p>
</div>
<Button variant="outline" size="sm" onClick={refresh} disabled={loading}>
<RefreshCw className={`mr-1.5 h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
Làm mới
</Button>
</div>
{error && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-2 text-sm text-destructive">
{error}
</div>
)}
{/* Top stats */}
{coverage && (
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
<StatCard
icon={<Layers className="h-4 w-4" />}
label="Đơn vị hành chính"
value={coverage.totals.administrativeUnits.toLocaleString('vi-VN')}
/>
<StatCard
icon={<MapPin className="h-4 w-4" />}
label="POI tổng"
value={coverage.totals.poiTotal.toLocaleString('vi-VN')}
/>
<StatCard
icon={<MapPin className="h-4 w-4 text-green-600" />}
label="KCN"
value={coverage.totals.industrialParks.toLocaleString('vi-VN')}
/>
<StatCard
icon={<Train className="h-4 w-4" />}
label="Bến/Ga"
value={coverage.totals.transportStations.toLocaleString('vi-VN')}
/>
<StatCard
icon={<Train className="h-4 w-4" />}
label="Tuyến giao thông"
value={coverage.totals.transportLines.toLocaleString('vi-VN')}
/>
</div>
)}
{/* Coverage table */}
<Card>
<CardContent className="p-0">
<div className="border-b border-border px-4 py-2.5">
<h2 className="text-sm font-semibold">Coverage theo layer</h2>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Layer / Category</TableHead>
<TableHead className="text-right">Tổng</TableHead>
<TableHead className="text-right">Promoted</TableHead>
<TableHead className="text-right">Raw</TableHead>
<TableHead>Sync gần nhất</TableHead>
<TableHead className="text-right">Hành đng</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{coverage?.rows.map((r) => {
const key = `${r.layer}/${r.category ?? '-'}`;
const layerDef = layers.find(
(l) => l.layer === r.layer && (l.category ?? null) === (r.category ?? null),
);
return (
<TableRow key={key}>
<TableCell>
<div className="font-medium">{r.layer}</div>
{r.category && (
<div className="text-xs text-muted-foreground">{r.category}</div>
)}
</TableCell>
<TableCell className="text-right font-mono">
{r.total.toLocaleString('vi-VN')}
{r.withGeometry !== undefined && r.withGeometry !== r.total && (
<span className="ml-1 text-xs text-muted-foreground">
({r.withGeometry} geom)
</span>
)}
</TableCell>
<TableCell className="text-right font-mono">
{r.promoted?.toLocaleString('vi-VN') ?? '—'}
</TableCell>
<TableCell className="text-right font-mono">
{r.raw?.toLocaleString('vi-VN') ?? '—'}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatRelative(r.lastSyncedAt)}
</TableCell>
<TableCell className="text-right">
{layerDef && (
<Button
variant="outline"
size="sm"
disabled={triggering === key}
onClick={() => trigger(r.layer, r.category ?? undefined)}
>
{triggering === key ? (
<RefreshCw className="mr-1 h-3 w-3 animate-spin" />
) : (
<PlayCircle className="mr-1 h-3 w-3" />
)}
Sync
</Button>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Recent runs */}
<Card>
<CardContent className="p-0">
<div className="border-b border-border px-4 py-2.5">
<h2 className="text-sm font-semibold">Sync runs gần đây</h2>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Layer</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Added</TableHead>
<TableHead className="text-right">Updated</TableHead>
<TableHead className="text-right">Skipped</TableHead>
<TableHead>Bắt đu</TableHead>
<TableHead>Thời gian</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-8 text-center text-sm text-muted-foreground">
Chưa sync run nào.
</TableCell>
</TableRow>
) : (
runs.map((r) => (
<TableRow key={r.id}>
<TableCell>
<div className="font-medium">{r.layer}</div>
{r.category && (
<div className="text-xs text-muted-foreground">{r.category}</div>
)}
</TableCell>
<TableCell>
<span
className={`inline-flex items-center gap-1 rounded-pill px-2 py-0.5 text-xs font-medium ring-1 ring-inset ${STATUS_STYLES[r.status]}`}
>
{STATUS_ICONS[r.status]}
{r.status}
</span>
</TableCell>
<TableCell className="text-right font-mono">{r.rowsAdded}</TableCell>
<TableCell className="text-right font-mono">{r.rowsUpdated}</TableCell>
<TableCell className="text-right font-mono">{r.rowsSkipped}</TableCell>
<TableCell className="text-sm text-muted-foreground">
<Clock className="mr-1 inline h-3 w-3" />
{formatRelative(r.startedAt)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDuration(r.startedAt, r.finishedAt)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
function StatCard({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
{icon}
</div>
<div className="min-w-0">
<div className="truncate text-xs uppercase text-muted-foreground">{label}</div>
<div className="truncate text-lg font-semibold">{value}</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -7,6 +7,7 @@ import {
ShieldCheck,
Building2,
Factory,
Globe,
LogOut,
Menu,
Sparkles,
@@ -37,6 +38,8 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{ href: '/admin/audit-log' as const, label: 'Nhật ký kiểm toán', icon: ScrollText },
{ href: '/admin/accounts/developers' as const, label: 'Tài khoản CĐT', icon: Building2 },
{ href: '/admin/accounts/park-operators' as const, label: 'Tài khoản KCN', icon: Factory },
{ href: '/admin/industrial/osm-review' as const, label: 'Review OSM (KCN)', icon: Factory },
{ href: '/admin/osm' as const, label: 'OSM Sync Dashboard', icon: Globe },
{ href: '/admin/settings/ai' as const, label: t('adminNav.aiSettings'), icon: Sparkles },
];

View File

@@ -16,21 +16,28 @@ import { Link } from '@/i18n/navigation';
import { useAuthStore } from '@/lib/auth-store';
import { loginSchema, type LoginFormData } from '@/lib/validations/auth';
const DEMO_PASSWORD = 'Velik@2026';
const ENABLE_DEMO_ACCOUNTS = process.env['NEXT_PUBLIC_ENABLE_DEMO_ACCOUNTS'] === 'true';
const DEMO_PASSWORD = process.env['NEXT_PUBLIC_DEMO_PASSWORD'] ?? '';
const DEMO_ACCOUNTS: {
type DemoAccount = {
phone: string;
name: string;
role: 'ADMIN' | 'AGENT' | 'SELLER' | 'BUYER' | 'DEVELOPER' | 'PARK_OPERATOR';
badgeClass: string;
}[] = [
{ phone: '+84876677771', name: 'Hồ Ngọc Hải', role: 'ADMIN', badgeClass: 'bg-red-500/10 text-red-600 border-red-500/20' },
{ phone: '+84900000002', name: 'Nguyễn Văn An', role: 'AGENT', badgeClass: 'bg-blue-500/10 text-blue-600 border-blue-500/20' },
{ phone: '+84900000005', name: 'Phạm Đức Dũng', role: 'SELLER', badgeClass: 'bg-amber-500/10 text-amber-600 border-amber-500/20' },
{ phone: '+84900000004', name: 'Lê Minh Cường', role: 'BUYER', badgeClass: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20' },
{ phone: '+84912000001', name: 'CĐT Vingroup', role: 'DEVELOPER', badgeClass: 'bg-violet-500/10 text-violet-600 border-violet-500/20' },
{ phone: '+84912000002', name: 'KCN VSIP', role: 'PARK_OPERATOR', badgeClass: 'bg-cyan-500/10 text-cyan-600 border-cyan-500/20' },
];
};
function parseDemoAccounts(): DemoAccount[] {
const raw = process.env['NEXT_PUBLIC_DEMO_ACCOUNTS'];
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as DemoAccount[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
const DEMO_ACCOUNTS = parseDemoAccounts();
export default function LoginPage() {
const router = useRouter();
@@ -39,6 +46,7 @@ export default function LoginPage() {
const [showPassword, setShowPassword] = useState(false);
const [demoOpen, setDemoOpen] = useState(true);
const t = useTranslations('auth');
const showDemoAccounts = ENABLE_DEMO_ACCOUNTS && DEMO_PASSWORD && DEMO_ACCOUNTS.length > 0;
const oauthError = searchParams.get('error');
const oauthErrorMessage = oauthError
@@ -76,48 +84,49 @@ export default function LoginPage() {
<CardDescription>{t('loginDescription')}</CardDescription>
</CardHeader>
<CardContent>
{/* Demo accounts panel — MVP only */}
<div className="mb-4 rounded-lg border border-primary/20 bg-primary/5">
<button
type="button"
onClick={() => setDemoOpen(!demoOpen)}
className="flex w-full items-center justify-between px-3 py-2 text-sm font-medium"
aria-expanded={demoOpen}
>
<span className="inline-flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" aria-hidden="true" />
{t('demoAccountsTitle')}
</span>
<ChevronDown
className={`h-4 w-4 text-muted-foreground transition-transform ${demoOpen ? 'rotate-180' : ''}`}
aria-hidden="true"
/>
</button>
{demoOpen && (
<div className="space-y-2 border-t border-primary/20 p-3">
<p className="text-xs text-muted-foreground">
{t('demoAccountsHint')} <code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">{DEMO_PASSWORD}</code>
</p>
<ul className="flex flex-col gap-1.5">
{DEMO_ACCOUNTS.map((acc) => (
<li key={acc.phone}>
<button
type="button"
onClick={() => fillDemoAccount(acc.phone)}
className="flex w-full items-center gap-2 rounded-md border bg-card px-2.5 py-1.5 text-left text-xs transition-colors hover:border-primary/40 hover:bg-primary/5"
>
<Badge variant="outline" className={`shrink-0 ${acc.badgeClass}`}>
{acc.role}
</Badge>
<span className="flex-1 truncate font-medium">{acc.name}</span>
<span className="font-mono text-muted-foreground">{acc.phone}</span>
</button>
</li>
))}
</ul>
</div>
)}
</div>
{showDemoAccounts && (
<div className="mb-4 rounded-lg border border-primary/20 bg-primary/5">
<button
type="button"
onClick={() => setDemoOpen(!demoOpen)}
className="flex w-full items-center justify-between px-3 py-2 text-sm font-medium"
aria-expanded={demoOpen}
>
<span className="inline-flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" aria-hidden="true" />
{t('demoAccountsTitle')}
</span>
<ChevronDown
className={`h-4 w-4 text-muted-foreground transition-transform ${demoOpen ? 'rotate-180' : ''}`}
aria-hidden="true"
/>
</button>
{demoOpen && (
<div className="space-y-2 border-t border-primary/20 p-3">
<p className="text-xs text-muted-foreground">
{t('demoAccountsHint')} <code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">{DEMO_PASSWORD}</code>
</p>
<ul className="flex flex-col gap-1.5">
{DEMO_ACCOUNTS.map((acc) => (
<li key={acc.phone}>
<button
type="button"
onClick={() => fillDemoAccount(acc.phone)}
className="flex w-full items-center gap-2 rounded-md border bg-card px-2.5 py-1.5 text-left text-xs transition-colors hover:border-primary/40 hover:bg-primary/5"
>
<Badge variant="outline" className={`shrink-0 ${acc.badgeClass}`}>
{acc.role}
</Badge>
<span className="flex-1 truncate font-medium">{acc.name}</span>
<span className="font-mono text-muted-foreground">{acc.phone}</span>
</button>
</li>
))}
</ul>
</div>
)}
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
{oauthErrorMessage && (

View File

@@ -123,13 +123,15 @@ describe('PublicLayout', () => {
expect(screen.getByTestId('navbar')).toBeInTheDocument();
});
it('renders the ticker strip', () => {
it('does not render a static ticker strip in the layout', () => {
// The layout-level mock ticker was removed in favour of the homepage's
// live DashboardTicker driven by /price-movers.
render(
<PublicLayout>
<div>Content</div>
</PublicLayout>,
);
expect(screen.getByTestId('ticker-strip')).toBeInTheDocument();
expect(screen.queryByTestId('ticker-strip')).not.toBeInTheDocument();
});
it('renders the footer', () => {
@@ -150,15 +152,6 @@ describe('PublicLayout', () => {
expect(screen.getByTestId('compare-bar')).toBeInTheDocument();
});
it('ticker strip has 8 district items', () => {
render(
<PublicLayout>
<div>Content</div>
</PublicLayout>,
);
expect(screen.getByText('8 items')).toBeInTheDocument();
});
it('main content has id="main-content" for skip-nav', () => {
const { container } = render(
<PublicLayout>

View File

@@ -1,22 +1,29 @@
'use client';
import { Factory, Map } from 'lucide-react';
import { Factory, Map as MapIcon, List, Columns } from 'lucide-react';
import * as React from 'react';
import { OsmMapLegend } from '@/components/khu-cong-nghiep/osm-map-legend';
import { OsmParkBboxMap } from '@/components/khu-cong-nghiep/osm-park-bbox-map';
import { ParkCard } from '@/components/khu-cong-nghiep/park-card';
import { ParkFilterBar } from '@/components/khu-cong-nghiep/park-filter-bar';
import { ParkMap } from '@/components/khu-cong-nghiep/park-map';
import { Button } from '@/components/ui/button';
import { useIndustrialParksSearch } from '@/lib/hooks/use-khu-cong-nghiep';
import type { SearchIndustrialParksParams } from '@/lib/khu-cong-nghiep-api';
const PAGE_SIZE = 12;
type ViewMode = 'list' | 'map' | 'split';
export default function KhuCongNghiepPage() {
const [filters, setFilters] = React.useState<SearchIndustrialParksParams>({
page: 1,
limit: PAGE_SIZE,
});
const [showMap, setShowMap] = React.useState(false);
const [viewMode, setViewMode] = React.useState<ViewMode>('split');
// When true, the bbox map also shows raw OSM-imported parks (amber
// markers) on top of the curated catalog. Off by default — most users
// want only the verified set.
const [includeOsmRaw, setIncludeOsmRaw] = React.useState(false);
const { data, isLoading, isError } = useIndustrialParksSearch(filters);
@@ -43,28 +50,44 @@ export default function KhuCongNghiepPage() {
{/* Filters */}
<ParkFilterBar params={filters} onChange={handleFilterChange} />
{/* Map toggle */}
{/* View mode toggle */}
<div className="mt-4 flex justify-end">
<Button
variant={showMap ? 'default' : 'outline'}
size="sm"
className="gap-2"
onClick={() => setShowMap(!showMap)}
>
<Map className="h-4 w-4" />
{showMap ? 'Ẩn bản đồ' : 'Xem bản đồ'}
</Button>
<div className="inline-flex gap-1 rounded-lg border p-1">
<Button
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
className="gap-1.5"
onClick={() => setViewMode('list')}
aria-pressed={viewMode === 'list'}
>
<List className="h-4 w-4" />
Danh sách
</Button>
<Button
variant={viewMode === 'map' ? 'default' : 'ghost'}
size="sm"
className="gap-1.5"
onClick={() => setViewMode('map')}
aria-pressed={viewMode === 'map'}
>
<MapIcon className="h-4 w-4" />
Bản đ
</Button>
<Button
variant={viewMode === 'split' ? 'default' : 'ghost'}
size="sm"
className="hidden gap-1.5 lg:inline-flex"
onClick={() => setViewMode('split')}
aria-pressed={viewMode === 'split'}
>
<Columns className="h-4 w-4" />
Chia đôi
</Button>
</div>
</div>
{/* Park Map */}
{showMap && data && data.data.length > 0 && (
<div className="mt-4">
<ParkMap parks={data.data} />
</div>
)}
{/* Results */}
<div className="mt-6">
<div className="mt-4">
{isLoading ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
@@ -93,14 +116,55 @@ export default function KhuCongNghiepPage() {
{data.total} khu công nghiệp đưc tìm thấy
</p>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{data.data.map((park) => (
<ParkCard key={park.id} park={park} />
))}
</div>
{/* Map-only view — bbox-driven, loads ALL parks in viewport */}
{viewMode === 'map' && (
<>
<OsmMapLegend
includeOsmRaw={includeOsmRaw}
onToggleOsmRaw={setIncludeOsmRaw}
/>
<OsmParkBboxMap
className="h-[calc(100vh-300px)]"
includeOsmRaw={includeOsmRaw}
/>
</>
)}
{/* Pagination */}
{data.totalPages > 1 && (
{/* Split view: list left, sticky bbox map right (lg+ only) */}
{viewMode === 'split' && (
<div className="grid gap-4 lg:grid-cols-2">
<div className="overflow-auto" style={{ maxHeight: 'calc(100vh - 220px)' }}>
<div className="grid gap-4 sm:grid-cols-2">
{data.data.map((park) => (
<ParkCard key={park.id} park={park} />
))}
</div>
</div>
<div className="hidden lg:flex lg:flex-col lg:gap-2">
<OsmMapLegend
includeOsmRaw={includeOsmRaw}
onToggleOsmRaw={setIncludeOsmRaw}
compact
/>
<OsmParkBboxMap
className="sticky top-20 h-[calc(100vh-260px)]"
includeOsmRaw={includeOsmRaw}
/>
</div>
</div>
)}
{/* List-only view */}
{viewMode === 'list' && (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{data.data.map((park) => (
<ParkCard key={park.id} park={park} />
))}
</div>
)}
{/* Pagination — show in list/split mode only */}
{viewMode !== 'map' && data.totalPages > 1 && (
<div className="mt-8 flex items-center justify-center gap-2">
<Button
variant="outline"

View File

@@ -5,7 +5,6 @@ import { useTranslations } from 'next-intl';
import { CompareFloatingBar } from '@/components/comparison/compare-floating-bar';
import { Footer } from '@/components/design-system/footer';
import { Navbar } from '@/components/design-system/navbar';
import { TickerStrip, type TickerItem } from '@/components/design-system/ticker-strip';
import { NotificationBell } from '@/components/notifications/notification-bell';
import { useTheme } from '@/components/providers/theme-provider';
import { LanguageSwitcher } from '@/components/ui/language-switcher';
@@ -78,17 +77,6 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
},
];
const tickerItems: TickerItem[] = [
{ id: 'q1', label: 'Quận 1', changePercent: 2.4, direction: 'up' },
{ id: 'q2', label: 'Thành phố Thủ Đức', changePercent: -0.8, direction: 'down' },
{ id: 'q3', label: 'Quận 3', changePercent: 1.1, direction: 'up' },
{ id: 'q7', label: 'Quận 7', changePercent: 3.2, direction: 'up' },
{ id: 'binhthanh', label: 'Bình Thạnh', changePercent: 0.0, direction: 'neutral' },
{ id: 'thuduc', label: 'Thành phố Thủ Đức', changePercent: 1.7, direction: 'up' },
{ id: 'tanbinhdistrict', label: 'Tân Bình', changePercent: -1.3, direction: 'down' },
{ id: 'phuninh', label: 'Phú Nhuận', changePercent: 0.5, direction: 'up' },
];
const footerLinkGroups = [
{
title: t('footer.propertyTypes'),
@@ -120,11 +108,6 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
return (
<div className="min-h-screen w-full overflow-x-clip bg-background">
{/* Ticker strip */}
<div className="h-ticker-bar w-full min-w-0 overflow-hidden border-b border-border bg-background-elevated">
<TickerStrip items={tickerItems} />
</div>
<Navbar
brand={t('common.goodgo')}
links={navLinks}

View File

@@ -25,9 +25,15 @@ import { listingsApi, type ListingDetail } from '@/lib/listings-api';
/* ------------------------------------------------------------------ */
/**
* Heatmap + district stats are aggregated quarterly in MarketIndex
* (`YYYY-QN`). The previous `YYYY-MM` format never matched any row, so
* the heatmap and district table came back empty.
*/
function currentPeriod(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
const quarter = Math.floor(now.getMonth() / 3) + 1;
return `${now.getFullYear()}-Q${quarter}`;
}
/* ------------------------------------------------------------------ */
@@ -421,12 +427,37 @@ export default function MarketDashboardPage() {
const districts: DistrictRow[] = React.useMemo(() => {
if (!districtData?.districts) return [];
return districtData.districts.map((d) => ({
district: d.district,
avgPriceM2: d.avgPriceM2,
yoyChange: d.yoyChange,
totalListings: d.totalListings,
daysOnMarket: d.daysOnMarket,
// Backend returns one row per (district × propertyType). For the
// homepage "Top khu vực" overview we collapse to one row per district,
// weighting averages by listing count so larger property types
// dominate, and using the median listings count for daysOnMarket.
const byDistrict = new Map<
string,
{ sumPriceTimesListings: number; totalListings: number; sumYoyTimesListings: number; sumYoyWeight: number; sumDaysTimesListings: number }
>();
for (const d of districtData.districts) {
const existing = byDistrict.get(d.district) ?? {
sumPriceTimesListings: 0,
totalListings: 0,
sumYoyTimesListings: 0,
sumYoyWeight: 0,
sumDaysTimesListings: 0,
};
existing.sumPriceTimesListings += d.avgPriceM2 * d.totalListings;
existing.totalListings += d.totalListings;
if (d.yoyChange != null) {
existing.sumYoyTimesListings += d.yoyChange * d.totalListings;
existing.sumYoyWeight += d.totalListings;
}
existing.sumDaysTimesListings += d.daysOnMarket * d.totalListings;
byDistrict.set(d.district, existing);
}
return Array.from(byDistrict.entries()).map(([district, agg]) => ({
district,
avgPriceM2: agg.totalListings > 0 ? agg.sumPriceTimesListings / agg.totalListings : 0,
yoyChange: agg.sumYoyWeight > 0 ? agg.sumYoyTimesListings / agg.sumYoyWeight : null,
totalListings: agg.totalListings,
daysOnMarket: agg.totalListings > 0 ? Math.round(agg.sumDaysTimesListings / agg.totalListings) : 0,
}));
}, [districtData]);

View File

@@ -87,10 +87,13 @@ function getFeatureValue(
key: string,
): boolean | number | string {
if (key === 'maxListings') {
return plan.maxListings === -1 ? 'Không giới hạn' : plan.maxListings;
// null and -1 both signal "unlimited" (seed uses null, business logic uses -1)
return plan.maxListings == null || plan.maxListings === -1
? 'Không giới hạn'
: plan.maxListings;
}
if (key === 'maxSavedSearches') {
return plan.maxSavedSearches === -1
return plan.maxSavedSearches == null || plan.maxSavedSearches === -1
? 'Không giới hạn'
: plan.maxSavedSearches;
}
@@ -300,17 +303,19 @@ export default function PricingPage() {
<li className="flex items-center gap-2">
<Check className="h-4 w-4 shrink-0 text-green-600" />
<span>
{plan.maxListings === -1
{plan.maxListings == null || plan.maxListings === -1
? t('unlimited')
: `${plan.maxListings} ${t('listingsCount')}`}
: `${plan.maxListings}`}{' '}
{t('listingsCount')}
</span>
</li>
<li className="flex items-center gap-2">
<Check className="h-4 w-4 shrink-0 text-green-600" />
<span>
{plan.maxSavedSearches === -1
{plan.maxSavedSearches == null || plan.maxSavedSearches === -1
? t('unlimited')
: `${plan.maxSavedSearches} ${t('savedSearchesCount')}`}
: `${plan.maxSavedSearches}`}{' '}
{t('savedSearchesCount')}
</span>
</li>
<li className="flex items-center gap-2">

View File

@@ -351,15 +351,19 @@ function SearchContent() {
</Button>
</div>
{/* Desktop horizontal filter bar */}
<div className="mb-4 hidden lg:block">
<FilterBar
filters={filters}
onChange={handleFilterChange}
onSearch={handleSearch}
layout="horizontal"
/>
</div>
{/* Desktop horizontal filter bar — only when there's no sidebar
(i.e. full-width map view). Showing it alongside the sidebar in
list/split mode would just duplicate every control. */}
{viewMode === 'map' && (
<div className="mb-4 hidden lg:block">
<FilterBar
filters={filters}
onChange={handleFilterChange}
onSearch={handleSearch}
layout="horizontal"
/>
</div>
)}
{/* Mobile filter panel */}
{showMobileFilters && (

View File

@@ -58,7 +58,7 @@ describe('PriceAreaChart', () => {
);
expect(screen.getByTestId('area-avgPriceM2')).toHaveAttribute(
'data-stroke',
'var(--color-signal-up)',
'hsl(var(--signal-up))',
);
});
@@ -73,7 +73,7 @@ describe('PriceAreaChart', () => {
);
expect(screen.getByTestId('area-avgPriceM2')).toHaveAttribute(
'data-stroke',
'var(--color-signal-down)',
'hsl(var(--signal-down))',
);
});
@@ -81,7 +81,7 @@ describe('PriceAreaChart', () => {
render(<PriceAreaChart data={[]} />);
expect(screen.getByTestId('area-avgPriceM2')).toHaveAttribute(
'data-stroke',
'var(--color-signal-down)',
'hsl(var(--signal-down))',
);
});

View File

@@ -29,12 +29,12 @@ export function PriceAreaChart({ data, height = 280, className }: PriceAreaChart
const isUp =
data.length >= 2 && data[data.length - 1]!.avgPriceM2 >= data[0]!.avgPriceM2;
const strokeColor = isUp
? 'var(--color-signal-up)'
: 'var(--color-signal-down)';
const fillColor = isUp
? 'var(--color-signal-up)'
: 'var(--color-signal-down)';
// CSS tokens are stored as raw HSL components (`--signal-up: 142 72% 50%`),
// so they must be wrapped in `hsl(...)`. The previous `var(--color-signal-up)`
// form referenced a non-existent variable, leaving recharts with `undefined`
// and rendering an invisible line/area.
const strokeColor = isUp ? 'hsl(var(--signal-up))' : 'hsl(var(--signal-down))';
const fillColor = strokeColor;
return (
<div className={className}>
@@ -48,17 +48,17 @@ export function PriceAreaChart({ data, height = 280, className }: PriceAreaChart
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--color-border)"
stroke="hsl(var(--border))"
strokeOpacity={0.5}
/>
<XAxis
dataKey="period"
tick={{ fontSize: 11, fill: 'var(--color-foreground-muted)' }}
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
/>
<YAxis
tick={{ fontSize: 11, fill: 'var(--color-foreground-muted)' }}
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) =>

View File

@@ -99,24 +99,31 @@ describe('Navbar', () => {
expect(screen.getAllByText('Nguyễn Văn A').length).toBeGreaterThan(0);
});
it('renders dashboard button for authenticated user', () => {
it('renders dashboard menu item for authenticated user (after opening dropdown)', () => {
render(
<Navbar
{...defaultProps}
user={{ fullName: 'Nguyễn Văn A', role: 'BUYER' }}
/>,
);
expect(screen.getByText('Quản lý')).toBeInTheDocument();
// The pill is the dropdown trigger; click it to reveal the menu.
const trigger = screen.getByRole('button', { name: /Nguyễn Văn A/ });
fireEvent.click(trigger);
expect(screen.getByRole('menuitem', { name: /Quản lý/ })).toBeInTheDocument();
});
it('renders admin label for ADMIN role', () => {
it('renders admin label as a role badge AND in the dropdown for ADMIN role', () => {
render(
<Navbar
{...defaultProps}
user={{ fullName: 'Admin User', role: 'ADMIN' }}
/>,
);
expect(screen.getByText('Quản trị')).toBeInTheDocument();
// Role badge in the trigger pill is always visible.
expect(screen.getByText('Quản trị viên')).toBeInTheDocument();
// After opening, the ADMIN-specific menu item shows.
fireEvent.click(screen.getByRole('button', { name: /Admin User/ }));
expect(screen.getByRole('menuitem', { name: /Quản trị/ })).toBeInTheDocument();
});
it('shows moon icon in light theme', () => {

View File

@@ -51,6 +51,12 @@ const SOCIAL_ICON: Record<string, React.ElementType> = {
youtube: ExternalLink,
};
const SOCIAL_LABEL: Record<string, string> = {
facebook: 'GoodGo Facebook',
instagram: 'GoodGo Instagram',
youtube: 'GoodGo YouTube',
};
/* -------------------------------------------------------------------------- */
/* Component */
/* -------------------------------------------------------------------------- */
@@ -123,8 +129,10 @@ export function Footer({
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
aria-label={SOCIAL_LABEL[s.platform] ?? s.platform}
title={SOCIAL_LABEL[s.platform] ?? s.platform}
>
{Icon && <Icon className="h-4 w-4" />}
{Icon && <Icon className="h-4 w-4" aria-hidden="true" />}
</a>
);
})}

View File

@@ -1,6 +1,7 @@
'use client';
import {
ChevronDown,
LogOut,
Menu,
Moon,
@@ -118,14 +119,36 @@ export function Navbar({
renderLink,
}: NavbarProps) {
const [mobileOpen, setMobileOpen] = React.useState(false);
const [userMenuOpen, setUserMenuOpen] = React.useState(false);
const userMenuRef = React.useRef<HTMLDivElement>(null);
const close = () => setMobileOpen(false);
const handleLogout = async () => {
close();
setUserMenuOpen(false);
await onLogout();
};
// Close the desktop user dropdown on outside click + Escape.
React.useEffect(() => {
if (!userMenuOpen) return;
const onDown = (e: MouseEvent) => {
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
setUserMenuOpen(false);
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setUserMenuOpen(false);
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [userMenuOpen]);
return (
<header
role="banner"
@@ -196,47 +219,123 @@ export function Navbar({
<>
<div className="hidden sm:block">{notifications}</div>
{/* User pill */}
<div className="hidden items-center gap-2 rounded-full border border-border bg-background-elevated px-2 py-1 sm:flex">
{user.avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={user.avatarUrl}
alt=""
className="h-6 w-6 rounded-full border object-cover"
{/* User dropdown — pill is the trigger, menu opens on click */}
<div ref={userMenuRef} className="relative hidden sm:block">
<button
type="button"
onClick={() => setUserMenuOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={userMenuOpen}
className="flex items-center gap-2 rounded-full border border-border bg-background-elevated px-2 py-1 text-left transition-colors hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
{user.avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={user.avatarUrl}
alt=""
className="h-6 w-6 rounded-full border object-cover"
/>
) : (
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/15 text-[10px] font-semibold text-primary">
{getInitials(user.fullName)}
</div>
)}
<span className="max-w-[10rem] truncate text-sm text-foreground">
{user.fullName}
</span>
{ROLE_LABELS[user.role] && (
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
{ROLE_LABELS[user.role]}
</span>
)}
<ChevronDown
className={cn(
'h-3.5 w-3.5 text-muted-foreground transition-transform',
userMenuOpen && 'rotate-180',
)}
aria-hidden
/>
) : (
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/15 text-[10px] font-semibold text-primary">
{getInitials(user.fullName)}
</button>
{userMenuOpen && (
<div
role="menu"
aria-label={user.fullName}
className="absolute right-0 top-full z-popover mt-2 w-56 overflow-hidden rounded-lg border border-border bg-background-elevated shadow-elevation-3"
>
{/* Header */}
<div className="flex items-center gap-2 border-b border-border bg-background-surface px-3 py-2.5">
{user.avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={user.avatarUrl}
alt=""
className="h-8 w-8 rounded-full border object-cover"
/>
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
{getInitials(user.fullName)}
</div>
)}
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium text-foreground">
{user.fullName}
</span>
{(user.email || user.phone) && (
<span className="truncate text-xs text-muted-foreground">
{user.email ?? user.phone}
</span>
)}
</div>
</div>
<div className="flex flex-col py-1">
{renderLink({
href: dashboardHref,
onClick: () => setUserMenuOpen(false),
children: (
<span
role="menuitem"
className="flex items-center gap-2 px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
{user.role === 'ADMIN' ? (
<Shield className="h-4 w-4" aria-hidden />
) : (
<LayoutDashboard className="h-4 w-4" aria-hidden />
)}
{user.role === 'ADMIN' ? labels.admin : labels.dashboard}
</span>
),
})}
{renderLink({
href: profileHref,
onClick: () => setUserMenuOpen(false),
children: (
<span
role="menuitem"
className="flex items-center gap-2 px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<UserIcon className="h-4 w-4" aria-hidden />
{labels.profile}
</span>
),
})}
<div className="my-1 border-t border-border" />
<button
type="button"
role="menuitem"
onClick={() => {
void handleLogout();
}}
className="flex items-center gap-2 px-3 py-2 text-left text-sm text-destructive transition-colors hover:bg-destructive/10"
>
<LogOut className="h-4 w-4" aria-hidden />
{labels.logout}
</button>
</div>
</div>
)}
<span className="max-w-[10rem] truncate text-sm text-foreground">
{user.fullName}
</span>
{ROLE_LABELS[user.role] && (
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
{ROLE_LABELS[user.role]}
</span>
)}
</div>
{renderLink({
href: dashboardHref,
className: 'hidden sm:inline-flex',
children: (
<button
type="button"
className="inline-flex h-9 items-center gap-1.5 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary-hover"
>
{user.role === 'ADMIN' ? (
<Shield className="h-3.5 w-3.5" aria-hidden />
) : (
<LayoutDashboard className="h-3.5 w-3.5" aria-hidden />
)}
{user.role === 'ADMIN' ? labels.admin : labels.dashboard}
</button>
),
})}
</>
) : (
<>

View File

@@ -13,12 +13,17 @@ import {
Zap,
} from 'lucide-react';
import * as React from 'react';
import { ParkMap } from '@/components/khu-cong-nghiep/park-map';
import { NearbyPoiSidebar } from '@/components/poi/nearby-poi-sidebar';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { type IndustrialParkDetail,
import {
type IndustrialParkDetail,
type IndustrialParkListItem,
PARK_STATUS_COLORS,
PARK_STATUS_LABELS,
REGION_LABELS } from '@/lib/khu-cong-nghiep-api';
REGION_LABELS,
} from '@/lib/khu-cong-nghiep-api';
import { cn } from '@/lib/utils';
type Tab = 'infrastructure' | 'connectivity' | 'incentives' | 'tenants' | 'documents';
@@ -35,6 +40,34 @@ interface KhuCongNghiepDetailClientProps {
park: IndustrialParkDetail;
}
/**
* The list-page ParkMap takes `IndustrialParkListItem[]`. Detail has the
* same fields plus a few extras — pick the subset the map actually uses
* so we can render a single-park view from the detail data.
*/
function parkAsListItem(p: IndustrialParkDetail): IndustrialParkListItem {
return {
id: p.id,
name: p.name,
nameEn: p.nameEn,
slug: p.slug,
developer: p.developer,
status: p.status,
province: p.province,
region: p.region,
totalAreaHa: p.totalAreaHa,
occupancyRate: p.occupancyRate,
remainingAreaHa: p.remainingAreaHa,
tenantCount: p.tenantCount,
landRentUsdM2Year: p.landRentUsdM2Year,
rbfRentUsdM2Month: p.rbfRentUsdM2Month,
rbwRentUsdM2Month: p.rbwRentUsdM2Month,
targetIndustries: p.targetIndustries,
latitude: p.latitude,
longitude: p.longitude,
};
}
export function KhuCongNghiepDetailClient({ park }: KhuCongNghiepDetailClientProps) {
const [activeTab, setActiveTab] = React.useState<Tab>('infrastructure');
@@ -131,6 +164,25 @@ export function KhuCongNghiepDetailClient({ park }: KhuCongNghiepDetailClientPro
</Card>
)}
{/* Vị trí trên bản đồ */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5" />
Vị trí trên bản đ
</CardTitle>
</CardHeader>
<CardContent>
<ParkMap
parks={[parkAsListItem(park)]}
className="h-[360px] md:h-[420px]"
/>
<p className="mt-2 text-xs text-muted-foreground">
{park.address}, {park.district}, {park.province}
</p>
</CardContent>
</Card>
{/* Target industries */}
{park.targetIndustries.length > 0 && (
<Card>
@@ -201,6 +253,16 @@ export function KhuCongNghiepDetailClient({ park }: KhuCongNghiepDetailClientPro
{/* Sidebar */}
<div className="space-y-6">
{/* OSM POI nearby (schools, hospitals, banks, transport, …) */}
{park.latitude != null && park.longitude != null && (
<NearbyPoiSidebar
lat={park.latitude}
lng={park.longitude}
radius={3000}
categories={['HOSPITAL', 'BANK', 'GAS_STATION', 'BUS_STATION', 'METRO_STATION', 'POLICE']}
/>
)}
{/* Rent info */}
<Card>
<CardHeader>

View File

@@ -0,0 +1,67 @@
'use client';
import { Info } from 'lucide-react';
import * as React from 'react';
interface OsmMapLegendProps {
includeOsmRaw: boolean;
onToggleOsmRaw: (value: boolean) => void;
/** Smaller variant for the split-view sidebar. */
compact?: boolean;
}
/**
* Legend + toggle that sits above the bbox map. Explains the two marker
* colors (curated vs raw OSM) and lets the user opt into showing the
* un-reviewed OpenStreetMap imports.
*/
export function OsmMapLegend({
includeOsmRaw,
onToggleOsmRaw,
compact = false,
}: OsmMapLegendProps) {
return (
<div
className={`flex flex-wrap items-center gap-3 rounded-lg border bg-card ${
compact ? 'px-3 py-2 text-xs' : 'px-4 py-2.5 text-sm'
}`}
role="group"
aria-label="Chú giải bản đồ KCN"
>
<div className="flex items-center gap-1.5">
<span
className="inline-block h-3 w-3 rounded-full border-2 border-white shadow"
style={{ backgroundColor: '#22c55e' }}
aria-hidden="true"
/>
<span className="text-foreground">KCN đã xác minh</span>
</div>
<div className="flex items-center gap-1.5">
<span
className="inline-block h-3 w-3 rounded-full border-2 border-white shadow opacity-70"
style={{ backgroundColor: '#f59e0b' }}
aria-hidden="true"
/>
<span className="text-foreground">KCN từ OpenStreetMap (chưa duyệt)</span>
</div>
<label className="ml-auto flex cursor-pointer items-center gap-1.5 select-none">
<input
type="checkbox"
checked={includeOsmRaw}
onChange={(e) => onToggleOsmRaw(e.target.checked)}
className="rounded border-border"
/>
<span>Hiển thị KCN OSM</span>
</label>
{includeOsmRaw && !compact && (
<p className="flex w-full items-center gap-1.5 border-t border-border pt-2 text-xs text-muted-foreground">
<Info className="h-3 w-3 shrink-0" />
KCN màu vàng dữ liệu thô từ OpenStreetMap, chưa đưc kiểm duyệt thông tin thể chưa
chính xác hoặc thiếu.
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,335 @@
'use client';
/* eslint-disable import-x/no-named-as-default-member */
import mapboxgl from 'mapbox-gl';
import * as React from 'react';
import 'mapbox-gl/dist/mapbox-gl.css';
import { useMapboxStyle } from '@/lib/mapbox-style';
const VN_CENTER: [number, number] = [106.0, 16.0];
const DEFAULT_ZOOM = 5;
const SOURCE_ID = 'osm-parks';
const CLUSTER_LAYER_ID = 'osm-parks-clusters';
const CLUSTER_COUNT_LAYER_ID = 'osm-parks-cluster-count';
const POINT_LAYER_ID = 'osm-parks-points';
const BOUNDARY_FILL_LAYER_ID = 'osm-parks-boundaries-fill';
const BOUNDARY_LINE_LAYER_ID = 'osm-parks-boundaries-line';
interface OsmParkBboxMapProps {
className?: string;
/** Override the bbox API path. Default = `${NEXT_PUBLIC_API_URL}/industrial/parks/by-bbox`. */
apiPath?: string;
/** Show raw OSM-imported parks (admin tools). Default false. */
includeOsmRaw?: boolean;
}
/**
* Viewport-driven KCN map. Pulls parks from the bbox endpoint as the user
* pans/zooms — clusters at low zoom (<12), shows polygon outlines at
* high zoom. Designed for the public catalog where we have ~2000 OSM
* imports + 50 curated rows; loading the entire dataset eagerly would
* be wasteful.
*/
export function OsmParkBboxMap({
className,
apiPath,
includeOsmRaw = false,
}: OsmParkBboxMapProps) {
const containerRef = React.useRef<HTMLDivElement>(null);
const mapRef = React.useRef<mapboxgl.Map | null>(null);
const fetchAbortRef = React.useRef<AbortController | null>(null);
const mapStyle = useMapboxStyle();
const apiBase = React.useMemo(() => {
if (apiPath) return apiPath;
const apiUrl = process.env['NEXT_PUBLIC_API_URL'] ?? 'http://localhost:3201/api/v1';
return `${apiUrl}/industrial/parks/by-bbox`;
}, [apiPath]);
// Capture the current includeOsmRaw value via a ref so the moveend
// handler always sees the latest without re-binding the listener.
const includeOsmRawRef = React.useRef(includeOsmRaw);
// Bumping this triggers a manual refetch when the toggle changes —
// the moveend handler alone doesn't fire on prop changes.
const refetchTokenRef = React.useRef<(() => void) | null>(null);
React.useEffect(() => {
includeOsmRawRef.current = includeOsmRaw;
refetchTokenRef.current?.();
}, [includeOsmRaw]);
React.useEffect(() => {
if (!containerRef.current) return;
const token = process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];
if (!token) return;
mapboxgl.accessToken = token;
const map = new mapboxgl.Map({
container: containerRef.current,
style: mapStyle,
center: VN_CENTER,
zoom: DEFAULT_ZOOM,
attributionControl: false,
});
map.addControl(new mapboxgl.NavigationControl(), 'top-right');
map.addControl(
new mapboxgl.AttributionControl({ compact: true, customAttribution: 'Data © OSM' }),
'bottom-right',
);
mapRef.current = map;
const fetchParks = async () => {
try {
// Cancel any in-flight request — only the latest viewport matters.
fetchAbortRef.current?.abort();
const controller = new AbortController();
fetchAbortRef.current = controller;
const bounds = map.getBounds();
if (!bounds) return;
const sw = bounds.getSouthWest();
const ne = bounds.getNorthEast();
const zoom = Math.round(map.getZoom());
const params = new URLSearchParams({
south: sw.lat.toString(),
west: sw.lng.toString(),
north: ne.lat.toString(),
east: ne.lng.toString(),
zoom: zoom.toString(),
...(includeOsmRawRef.current ? { includeOsmRaw: 'true' } : {}),
});
const res = await fetch(`${apiBase}?${params}`, {
credentials: 'include',
signal: controller.signal,
});
if (!res.ok) return;
const fc = (await res.json()) as GeoJSON.FeatureCollection;
const src = map.getSource(SOURCE_ID) as mapboxgl.GeoJSONSource | undefined;
if (src) src.setData(fc);
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return;
console.warn('[osm-park-bbox-map] fetch failed:', err);
}
};
map.on('load', () => {
// Empty source — populated by the first fetchParks() call below.
map.addSource(SOURCE_ID, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
cluster: true,
clusterRadius: 50,
clusterMaxZoom: 11,
clusterProperties: {
// No extra metrics yet — total count is built-in.
},
});
// Cluster bubbles. Mapbox color parser only accepts literal colors,
// so we use hex constants matching our design-system primary token.
map.addLayer({
id: CLUSTER_LAYER_ID,
type: 'circle',
source: SOURCE_ID,
filter: ['has', 'point_count'],
paint: {
'circle-color': [
'step',
['get', 'point_count'],
'#22c55e', // primary
10,
'#f59e0b',
50,
'#ef4444',
],
'circle-radius': [
'step',
['get', 'point_count'],
16,
10,
22,
50,
30,
],
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff',
'circle-opacity': 0.9,
},
});
map.addLayer({
id: CLUSTER_COUNT_LAYER_ID,
type: 'symbol',
source: SOURCE_ID,
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
'text-size': 12,
},
paint: { 'text-color': '#ffffff' },
});
// Individual park markers (centroid Points) when not clustered.
// Color is data-driven: green = curated (MANUAL / OSM_PROMOTED),
// amber = raw OSM imports awaiting admin review.
map.addLayer({
id: POINT_LAYER_ID,
type: 'circle',
source: SOURCE_ID,
filter: [
'all',
['!', ['has', 'point_count']],
['==', ['get', '_kind'], 'point'],
],
paint: {
'circle-color': [
'case',
['==', ['get', 'dataSource'], 'OSM'],
'#f59e0b', // amber for raw OSM
'#22c55e', // green for curated
],
'circle-radius': [
'case',
['==', ['get', 'dataSource'], 'OSM'],
5,
6,
],
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 1.5,
'circle-opacity': [
'case',
['==', ['get', 'dataSource'], 'OSM'],
0.7,
1,
],
},
});
// Polygon outlines — only present when zoom >= 12 (server omits them
// at lower zoom). Fill layer for hit-test, line layer for stroke.
map.addLayer({
id: BOUNDARY_FILL_LAYER_ID,
type: 'fill',
source: SOURCE_ID,
filter: ['==', ['get', '_kind'], 'polygon'],
paint: {
'fill-color': [
'case',
['==', ['get', 'dataSource'], 'OSM'],
'#f59e0b',
'#22c55e',
],
'fill-opacity': [
'case',
['==', ['get', 'dataSource'], 'OSM'],
0.1,
0.18,
],
},
});
map.addLayer({
id: BOUNDARY_LINE_LAYER_ID,
type: 'line',
source: SOURCE_ID,
filter: ['==', ['get', '_kind'], 'polygon'],
paint: {
'line-color': [
'case',
['==', ['get', 'dataSource'], 'OSM'],
'#f59e0b',
'#22c55e',
],
'line-width': 2,
'line-opacity': [
'case',
['==', ['get', 'dataSource'], 'OSM'],
0.4,
0.6,
],
},
});
// Click handler on point/polygon → navigate to detail.
const onClick = (e: mapboxgl.MapLayerMouseEvent) => {
const f = e.features?.[0];
if (!f) return;
const slug = (f.properties as Record<string, unknown> | null)?.['slug'];
if (typeof slug === 'string' && slug.length > 0) {
window.location.href = `/vi/khu-cong-nghiep/${slug}`;
}
};
map.on('click', POINT_LAYER_ID, onClick);
map.on('click', BOUNDARY_FILL_LAYER_ID, onClick);
// Cursor feedback
for (const layerId of [POINT_LAYER_ID, BOUNDARY_FILL_LAYER_ID, CLUSTER_LAYER_ID]) {
map.on('mouseenter', layerId, () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', layerId, () => {
map.getCanvas().style.cursor = '';
});
}
// Cluster click — zoom in
map.on('click', CLUSTER_LAYER_ID, (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: [CLUSTER_LAYER_ID] });
const clusterFeature = features[0];
if (!clusterFeature) return;
const clusterId = clusterFeature.properties?.['cluster_id'];
const src = map.getSource(SOURCE_ID) as mapboxgl.GeoJSONSource;
if (typeof clusterId === 'number') {
src.getClusterExpansionZoom(
clusterId,
(err: Error | null | undefined, zoom: number | null | undefined) => {
if (err || zoom == null) return;
const geom = clusterFeature.geometry;
if (geom.type === 'Point') {
map.easeTo({ center: geom.coordinates as [number, number], zoom });
}
},
);
}
});
// Initial fetch + listen to viewport changes.
void fetchParks();
// Wire up the prop-change refetch (used when `includeOsmRaw` flips
// — the moveend listener alone doesn't fire on parent re-renders).
refetchTokenRef.current = () => {
void fetchParks();
};
});
map.on('moveend', () => {
void fetchParks();
});
return () => {
fetchAbortRef.current?.abort();
map.remove();
mapRef.current = null;
};
// We intentionally do NOT depend on includeOsmRaw — the ref-based
// approach avoids tearing down the map on every prop tick.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [apiBase]);
// Sync mapStyle (theme switch) without rebuilding the map.
React.useEffect(() => {
const map = mapRef.current;
if (!map) return;
map.setStyle(mapStyle);
}, [mapStyle]);
const hasToken = typeof process !== 'undefined' && process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];
return (
<div className={`relative overflow-hidden rounded-lg border ${className || 'h-[600px]'}`}>
<div ref={containerRef} className="h-full w-full" />
{!hasToken && (
<div className="absolute inset-0 flex items-center justify-center bg-muted text-sm text-muted-foreground">
Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN đ hiển thị bản đ
</div>
)}
</div>
);
}

View File

@@ -15,6 +15,13 @@ vi.mock('next/link', () => ({
),
}));
// Mock locale-aware navigation links
vi.mock('@/i18n/navigation', () => ({
Link: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
}));
// Mock next/dynamic to render children directly
vi.mock('next/dynamic', () => ({
default: () => {

View File

@@ -1,7 +1,6 @@
'use client';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import * as React from 'react';
import { AddToCompareButton } from '@/components/comparison/add-to-compare-button';
import { AiAdviceCards } from '@/components/listings/ai-advice-cards';
@@ -11,11 +10,13 @@ import { PriceHistoryChart } from '@/components/listings/price-history-chart';
import { ReportListingModal } from '@/components/listings/report-listing-modal';
import { SocialShare } from '@/components/listings/social-share';
import type { POIItem } from '@/components/neighborhood';
import { NearbyPoiSidebar } from '@/components/poi/nearby-poi-sidebar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { AiEstimateButton } from '@/components/valuation/ai-estimate-button';
import { analyticsApi, type NearbyPOI } from '@/lib/analytics-api';
import { Link } from '@/i18n/navigation';
import { analyticsApi, type NearbyPOI } from '@/lib/analytics-api';
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
import { composeWhyThisLocation, derivePersonas } from '@/lib/listing-personas';
import {
@@ -897,6 +898,15 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) {
onOpenChange={setReportOpen}
/>
{/* OSM POI nearby — schools, hospitals, markets, banks, metro… */}
{property.latitude != null && property.longitude != null && (
<NearbyPoiSidebar
lat={property.latitude}
lng={property.longitude}
radius={1500}
/>
)}
{/* Stats */}
<Card>
<CardContent className="pt-5">

View File

@@ -49,7 +49,7 @@ export function Sparkline({ listingId, width = 64, height = 20 }: SparklineProps
// Color based on trend direction
const trending = prices[prices.length - 1]! >= prices[0]!;
const strokeColor = trending ? 'var(--color-signal-up)' : 'var(--color-signal-down)';
const strokeColor = trending ? 'hsl(var(--signal-up))' : 'hsl(var(--signal-down))';
return (
<svg

View File

@@ -198,7 +198,9 @@ function ListingMapInner({
clusterRadius: 50,
});
// Cluster circles
// Cluster circles. Mapbox-gl's color parser rejects `hsl(var(--…))` —
// it only accepts literal CSS colors. We use hex constants tuned to
// match the design-system primary/accent palette in dark mode.
map.addLayer({
id: CLUSTER_LAYER_ID,
type: 'circle',
@@ -208,7 +210,7 @@ function ListingMapInner({
'circle-color': [
'step',
['get', 'point_count'],
'hsl(var(--primary))',
'#22c55e', // primary (emerald-500)
10,
'#f1a928',
30,
@@ -248,8 +250,8 @@ function ListingMapInner({
'text-allow-overlap': true,
},
paint: {
'text-color': 'hsl(var(--card-foreground))',
'text-halo-color': 'hsl(var(--card))',
'text-color': '#f5f5f4', // card-foreground (stone-100)
'text-halo-color': '#1c1917', // card (stone-900)
'text-halo-width': 8,
},
});
@@ -269,8 +271,8 @@ function ListingMapInner({
'text-allow-overlap': true,
},
paint: {
'text-color': 'hsl(var(--primary-foreground))',
'text-halo-color': 'hsl(var(--primary))',
'text-color': '#ffffff', // primary-foreground (high-contrast on emerald)
'text-halo-color': '#22c55e', // primary (emerald-500)
'text-halo-width': 10,
},
});

View File

@@ -195,10 +195,11 @@ export function NeighborhoodPOIMap({
filter: ['has', 'point_count'],
paint: {
// Small clusters: primary; medium: amber; large: red
// (Mapbox-gl's color parser rejects `hsl(var(--…))` — use literal hex)
'circle-color': [
'step',
['get', 'point_count'],
'hsl(var(--primary))',
'#22c55e', // primary (emerald-500) — matches design-system token
5,
'#f59e0b',
20,

View File

@@ -0,0 +1,151 @@
'use client';
import { Loader2, MapPin } from 'lucide-react';
import * as React from 'react';
import {
POI_ICONS,
POI_LABELS,
poiApi,
type NearbyPoiResult,
type PoiCategory,
} from '@/lib/poi-api';
interface Props {
/** Centre coordinates of the asset (listing / project / KCN). */
lat: number;
lng: number;
/** Search radius in metres. Default 1500m (~15 phút đi bộ). */
radius?: number;
/** Restrict to these categories. Default: 6 most relevant for residential. */
categories?: PoiCategory[];
/** N nearest POI shown per category. */
limitPerCategory?: number;
className?: string;
}
const DEFAULT_CATEGORIES: PoiCategory[] = [
'SCHOOL_PRIMARY',
'SCHOOL_SECONDARY',
'HOSPITAL',
'MARKET',
'BANK',
'METRO_STATION',
];
function formatDistance(m: number): string {
if (m < 1000) return `${m} m`;
return `${(m / 1000).toFixed(1)} km`;
}
/**
* Sidebar widget that lists the nearest POI of each category around a
* geo-tagged asset. Renders inside listing detail, project detail and KCN
* detail pages.
*/
export function NearbyPoiSidebar({
lat,
lng,
radius = 1500,
categories = DEFAULT_CATEGORIES,
limitPerCategory = 3,
className,
}: Props) {
const [data, setData] = React.useState<NearbyPoiResult | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
poiApi
.nearby({ lat, lng, radius, categories, limitPerCategory })
.then((res) => {
if (cancelled) return;
setData(res);
})
.catch((err: Error) => {
if (cancelled) return;
setError(err.message ?? 'Không tải được tiện ích');
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [lat, lng, radius, categories, limitPerCategory]);
if (loading) {
return (
<div
className={`flex items-center gap-2 rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground ${className ?? ''}`}
>
<Loader2 className="h-4 w-4 animate-spin" />
Đang tải tiện ích xung quanh
</div>
);
}
if (error) {
return (
<div
className={`rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive ${className ?? ''}`}
>
{error}
</div>
);
}
if (!data || data.all.length === 0) {
return (
<div
className={`rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground ${className ?? ''}`}
>
Chưa dữ liệu tiện ích trong bán kính {formatDistance(radius)}.
</div>
);
}
return (
<div className={`rounded-lg border border-border bg-card ${className ?? ''}`}>
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
<h3 className="text-sm font-semibold text-foreground">Tiện ích xung quanh</h3>
<span className="text-xs text-muted-foreground">
{data.meta.totalCount} điểm · bán kính {formatDistance(data.meta.radiusMeters)}
</span>
</div>
<div className="flex flex-col divide-y divide-border">
{categories.map((cat) => {
const items = data.byCategory[cat] ?? [];
if (items.length === 0) return null;
return (
<div key={cat} className="px-4 py-2.5">
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium uppercase text-muted-foreground">
<span aria-hidden>{POI_ICONS[cat]}</span>
{POI_LABELS[cat]}
</div>
<ul className="flex flex-col gap-1.5">
{items.map((p) => (
<li key={p.id} className="flex items-start gap-2 text-sm">
<MapPin className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-foreground">{p.name}</div>
{p.address && (
<div className="truncate text-xs text-muted-foreground">{p.address}</div>
)}
</div>
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
{formatDistance(p.distanceM)}
</span>
</li>
))}
</ul>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,173 @@
'use client';
import { ChevronDown } from 'lucide-react';
import * as React from 'react';
import { POI_ICONS, POI_LABELS, type PoiCategory } from '@/lib/poi-api';
export interface PoiNearbyConstraint {
/** Required POI categories — listing must have at least one of each within radius. */
categories: PoiCategory[];
/** Radius in metres (50 - 5000). */
radiusM: number;
}
interface Props {
value: PoiNearbyConstraint;
onChange: (next: PoiNearbyConstraint) => void;
className?: string;
}
const RADIUS_OPTIONS = [
{ value: 300, label: '300m (đi bộ 5 phút)' },
{ value: 500, label: '500m' },
{ value: 1000, label: '1 km' },
{ value: 1500, label: '1.5 km' },
{ value: 2500, label: '2.5 km' },
{ value: 5000, label: '5 km (xe máy 10 phút)' },
];
const QUICK_PRESETS: { label: string; categories: PoiCategory[] }[] = [
{ label: 'Gia đình con nhỏ', categories: ['SCHOOL_PRIMARY', 'HOSPITAL', 'MARKET'] },
{ label: 'Đi làm văn phòng', categories: ['BUS_STATION', 'METRO_STATION', 'BANK'] },
{ label: 'Tiện nghi', categories: ['SUPERMARKET', 'PARK', 'PHARMACY'] },
];
const ALL_GROUPS: { label: string; items: PoiCategory[] }[] = [
{ label: 'Giáo dục', items: ['SCHOOL_PRIMARY', 'SCHOOL_SECONDARY', 'UNIVERSITY'] },
{ label: 'Y tế', items: ['HOSPITAL', 'CLINIC', 'PHARMACY'] },
{ label: 'Thương mại', items: ['MARKET', 'SUPERMARKET', 'MALL', 'CONVENIENCE'] },
{ label: 'Tài chính', items: ['BANK', 'ATM'] },
{ label: 'Giao thông', items: ['METRO_STATION', 'BUS_STATION', 'RAILWAY_STATION', 'AIRPORT'] },
{ label: 'Khác', items: ['PARK', 'GAS_STATION', 'POLICE', 'POST_OFFICE'] },
];
/**
* Compact filter widget for the search page: pick "in X meters" + which
* POI categories are required nearby. Designed to slot into an existing
* search filter bar — see `apps/web/app/[locale]/(public)/search/page.tsx`.
*/
export function PoiSearchFilter({ value, onChange, className }: Props) {
const [open, setOpen] = React.useState(false);
const ref = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}, [open]);
const toggle = (cat: PoiCategory) => {
const next = value.categories.includes(cat)
? value.categories.filter((c) => c !== cat)
: [...value.categories, cat];
onChange({ ...value, categories: next });
};
const summary =
value.categories.length === 0
? 'Tiện ích xung quanh'
: `${value.categories.length} tiện ích · ${value.radiusM >= 1000 ? `${value.radiusM / 1000}km` : `${value.radiusM}m`}`;
return (
<div ref={ref} className={`relative ${className ?? ''}`}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="dialog"
aria-expanded={open}
className="flex h-9 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm text-foreground transition-colors hover:bg-accent"
>
<span>{summary}</span>
<ChevronDown
className={`h-3.5 w-3.5 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && (
<div className="absolute right-0 top-full z-popover mt-2 w-[28rem] max-w-[90vw] overflow-hidden rounded-lg border border-border bg-card shadow-elevation-3">
{/* Radius */}
<div className="border-b border-border p-3">
<label className="mb-1 block text-xs font-medium text-muted-foreground">
Trong bán kính
</label>
<select
value={value.radiusM}
onChange={(e) => onChange({ ...value, radiusM: Number(e.target.value) })}
className="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
>
{RADIUS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
{/* Quick presets */}
<div className="border-b border-border p-3">
<div className="mb-1.5 text-xs font-medium text-muted-foreground">Gợi ý</div>
<div className="flex flex-wrap gap-1.5">
{QUICK_PRESETS.map((p) => (
<button
key={p.label}
type="button"
onClick={() => onChange({ ...value, categories: p.categories })}
className="rounded-full border border-border bg-background px-2.5 py-1 text-xs transition-colors hover:bg-accent"
>
{p.label}
</button>
))}
{value.categories.length > 0 && (
<button
type="button"
onClick={() => onChange({ ...value, categories: [] })}
className="rounded-full border border-destructive/30 bg-destructive/10 px-2.5 py-1 text-xs text-destructive transition-colors hover:bg-destructive/15"
>
Bỏ chọn
</button>
)}
</div>
</div>
{/* Category groups */}
<div className="max-h-72 overflow-y-auto p-3">
{ALL_GROUPS.map((g) => (
<div key={g.label} className="mb-2 last:mb-0">
<div className="mb-1 text-xs font-medium uppercase text-muted-foreground">
{g.label}
</div>
<div className="grid grid-cols-2 gap-1.5">
{g.items.map((cat) => {
const checked = value.categories.includes(cat);
return (
<label
key={cat}
className={`flex cursor-pointer items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors ${
checked
? 'border-primary bg-primary/10 text-primary'
: 'border-border bg-background hover:bg-accent'
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggle(cat)}
className="h-3 w-3"
/>
<span aria-hidden>{POI_ICONS[cat]}</span>
<span className="truncate">{POI_LABELS[cat]}</span>
</label>
);
})}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -90,6 +90,7 @@ function SearchResultsInner({
value={sort}
onChange={(e) => onSortChange(e.target.value)}
className="w-full sm:w-48"
aria-label="Sắp xếp kết quả tìm kiếm"
>
<option value="">Mới nhất</option>
<option value="price_asc">Giá: Thấp đến cao</option>

View File

@@ -132,6 +132,32 @@ describe('CheckoutModal', () => {
provider: 'VNPAY',
type: 'SUBSCRIPTION',
amountVND: 499000,
returnUrl: 'http://localhost:3000/vi/payment/return',
}),
);
});
});
it('uses the locale root payment return route from dashboard checkout', async () => {
Object.defineProperty(window, 'location', {
writable: true,
value: {
...window.location,
href: 'http://localhost:3000/vi/dashboard/subscription',
origin: 'http://localhost:3000',
pathname: '/vi/dashboard/subscription',
},
});
render(
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" />,
);
await userEvent.click(screen.getByRole('button', { name: /thanh toán/i }));
await waitFor(() => {
expect(mockCreatePayment).toHaveBeenCalledWith(
expect.objectContaining({
returnUrl: 'http://localhost:3000/vi/payment/return',
}),
);
});

View File

@@ -120,7 +120,9 @@ function CheckoutModalInner({
}
// Step 2: Create payment and redirect to gateway
const returnUrl = `${window.location.origin}${window.location.pathname.replace(/\/pricing$/, '')}/payment/return`;
const localeMatch = window.location.pathname.match(/^\/(vi|en)(\/|$)/);
const localePrefix = localeMatch?.[1] ? `/${localeMatch[1]}` : '';
const returnUrl = `${window.location.origin}${localePrefix}/payment/return`;
const idempotencyKey = `sub-${plan.tier}-${billingCycle}-${Date.now()}`;

View File

@@ -176,7 +176,7 @@ export const adminApi = {
apiClient.get<UserDetail>(`/admin/users/${userId}`),
updateUserStatus: (userId: string, isActive: boolean, reason?: string) =>
apiClient.post<{ success: boolean }>('/admin/users/status', {
apiClient.patch<{ success: boolean }>('/admin/users/status', {
userId,
isActive,
reason,

View File

@@ -121,6 +121,9 @@ export const apiClient = {
patch: <T>(endpoint: string, body?: unknown, headers?: HeadersInit) =>
request<T>(endpoint, { method: 'PATCH', body, headers }),
put: <T>(endpoint: string, body?: unknown, headers?: HeadersInit) =>
request<T>(endpoint, { method: 'PUT', body, headers }),
delete: <T>(endpoint: string, headers?: HeadersInit) =>
request<T>(endpoint, { method: 'DELETE', headers }),
};

View File

@@ -21,16 +21,23 @@ export const analyticsKeys = {
};
/**
* Analytics endpoints require authentication on the backend. Guard React Query
* hooks with `isAuthenticated` so unauthenticated visitors on public routes
* (e.g. homepage) do not fire requests that return 401 and spam the console.
* Analytics endpoints currently require authentication on the backend. We
* gate React Query hooks on `isInitialized` (not `isAuthenticated`) so that:
*
* - Authenticated visitors fire queries the moment `initialize()` finishes,
* even if the React subscription to `isAuthenticated` lags a tick behind
* (we previously saw the homepage stay stuck on "Đang tải..." because the
* gate stayed `false` after the first render and React-Query never refetched).
* - Anonymous visitors fire one request that returns 401 — react-query
* handles this gracefully (silent toast-less rejection in api-client) and
* the components fall back to empty states.
*
* The 401 cost for anon users is preferable to a perpetually empty homepage
* for authed users.
*/
function useAuthedAnalytics() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const isInitialized = useAuthStore((s) => s.isInitialized);
// Only enable queries once auth state has initialized to avoid a spurious
// disabled → enabled transition on first paint.
return isInitialized && isAuthenticated;
return isInitialized;
}
export function useMarketReport(city: string, period: string) {

View File

@@ -233,6 +233,49 @@ export interface SearchIndustrialParksParams {
limit?: number;
}
// ─── OSM Admin Types ────────────────────────────────────
export interface OsmPendingItem {
id: string;
slug: string;
name: string;
nameEn: string | null;
province: string;
district: string;
region: string;
status: string;
/** OSM relation/way/node id, serialised as string (BigInt). */
osmId: string;
osmType: 'NODE' | 'WAY' | 'RELATION' | null;
/** Raw OSM tags object — varies wildly per row. */
osmTags: Record<string, string> | null;
totalAreaHa: number;
developer: string;
operator: string | null;
osmLocked: boolean;
lastSyncedAt: string | null;
latitude: number | null;
longitude: number | null;
}
export interface OsmPendingResult {
data: OsmPendingItem[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface ListOsmPendingParams {
page?: number;
limit?: number;
q?: string;
province?: string;
/** Diện tích tối thiểu (ha). Default backend = 50 để lọc bớt nhà máy lẻ. */
minAreaHa?: number;
region?: VietnamRegion;
}
// ─── Labels ─────────────────────────────────────────────
export const PARK_STATUS_LABELS: Record<IndustrialParkStatus, string> = {
@@ -328,4 +371,31 @@ export const industrialApi = {
deletePark: (id: string) =>
apiClient.delete<{ success: boolean }>(`/industrial/parks/${id}`),
// ─── OSM admin endpoints (ADMIN role only) ───────────
listOsmPending: (params: ListOsmPendingParams = {}) => {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== '') query.append(key, String(value));
});
const qs = query.toString();
return apiClient.get<OsmPendingResult>(
`/industrial/parks/osm/pending${qs ? `?${qs}` : ''}`,
);
},
/** Promote OSM row → public OSM_PROMOTED. Optionally lock fields the admin
* just edited so the next sync run leaves them alone. */
promoteOsm: (id: string, lockFields: string[] = []) =>
apiClient.post<{ id: string }>(`/industrial/parks/${id}/osm/promote`, {
lockFields,
}),
/** Toggle the row-level OSM lock. When `true`, sync skips this row entirely. */
lockOsm: (id: string, locked: boolean) =>
apiClient.post<{ id: string; locked: boolean }>(
`/industrial/parks/${id}/osm/lock`,
{ locked },
),
};

View File

@@ -268,7 +268,7 @@ export const listingsApi = {
},
updateStatus: (id: string, status: ListingStatus, moderationNotes?: string) =>
apiClient.post<{ status: string }>(`/listings/${id}/status`, {
apiClient.patch<{ status: string }>(`/listings/${id}/status`, {
status,
moderationNotes,
}),

View File

@@ -16,7 +16,9 @@ const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:300
export async function fetchListingById(id: string): Promise<ListingDetail | null> {
try {
const res = await fetch(`${API_BASE_URL}/listings/${id}`, {
next: { revalidate: 300 }, // ISR: re-validate every 5 min
// Listing detail includes mutable status, price, legal and moderation data.
// Avoid serving stale details after admin/user actions.
cache: 'no-store',
});
if (!res.ok) return null;

View File

@@ -0,0 +1,58 @@
import { apiClient } from './api-client';
export interface OsmCoverageRow {
layer: string;
category: string | null;
total: number;
withGeometry?: number;
promoted?: number;
raw?: number;
lastSyncedAt: string | null;
}
export interface OsmCoverageSummary {
rows: OsmCoverageRow[];
totals: {
administrativeUnits: number;
poiTotal: number;
industrialParks: number;
transportStations: number;
transportLines: number;
};
}
export interface OsmSyncRun {
id: string;
layer: string;
category: string | null;
chunk: string | null;
startedAt: string;
finishedAt: string | null;
status: 'RUNNING' | 'SUCCESS' | 'PARTIAL' | 'FAILED';
rowsAdded: number;
rowsUpdated: number;
rowsSkipped: number;
rowsLocked: number;
errorMessage: string | null;
}
export interface OsmSyncLayer {
layer: string;
category?: string;
weight: 'light' | 'medium' | 'heavy';
}
export const osmSyncApi = {
layers: () => apiClient.get<OsmSyncLayer[]>('/admin/osm/layers'),
coverage: () => apiClient.get<OsmCoverageSummary>('/admin/osm/coverage'),
runs: (params: { layer?: string; status?: string; limit?: number } = {}) => {
const q = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== '') q.append(k, String(v));
});
const qs = q.toString();
return apiClient.get<OsmSyncRun[]>(`/admin/osm/runs${qs ? `?${qs}` : ''}`);
},
trigger: (body: { layer: string; category?: string; chunk?: string }) =>
apiClient.post<{ runId: string; status: string }>('/admin/osm/runs', body),
};

Some files were not shown because too many files have changed in this diff Show More