Compare commits
35 Commits
489d61a27b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c735f3097 | ||
|
|
38494a4bec | ||
|
|
b35ec55126 | ||
|
|
f82806e06d | ||
|
|
bb379b5c1b | ||
|
|
39156fc107 | ||
|
|
f112045826 | ||
|
|
dd67045e00 | ||
|
|
69ceb56316 | ||
|
|
5ed0993f74 | ||
|
|
388bc972c1 | ||
|
|
57cd84aebf | ||
|
|
1e9ef567a9 | ||
|
|
884a8d2a63 | ||
|
|
a9770a5f93 | ||
|
|
fba536406d | ||
|
|
73ff469126 | ||
|
|
1ae36f7f98 | ||
|
|
cec643ce5f | ||
|
|
416d1a5959 | ||
|
|
a38c797846 | ||
|
|
d6ac7c316f | ||
|
|
63a449ad9d | ||
|
|
c15bdcc6bf | ||
|
|
e7ca4fe8b1 | ||
|
|
b3143991ce | ||
|
|
99f305f6ba | ||
|
|
a7fb5295b8 | ||
|
|
58209b2434 | ||
|
|
405f2a3623 | ||
|
|
925863e471 | ||
|
|
b9a1a24f65 | ||
|
|
8825a13d1d | ||
|
|
54670b4bd4 | ||
|
|
f222611fcf |
22
.env.example
22
.env.example
@@ -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
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
83
.github/workflows/ci.yml
vendored
83
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
61
.github/workflows/codeql.yml
vendored
61
.github/workflows/codeql.yml
vendored
@@ -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
|
||||
93
.github/workflows/deploy.yml
vendored
93
.github/workflows/deploy.yml
vendored
@@ -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
|
||||
|
||||
|
||||
100
.github/workflows/e2e.yml
vendored
100
.github/workflows/e2e.yml
vendored
@@ -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
|
||||
|
||||
76
.github/workflows/security.yml
vendored
76
.github/workflows/security.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -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
97
AGENTS.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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[] = [],
|
||||
) {}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/** Aggregate coverage view across all OSM-managed tables for the
|
||||
* admin dashboard. */
|
||||
export class OsmCoverageSummaryQuery {}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
255
apps/api/src/modules/osm-sync/infrastructure/osm-sync.service.ts
Normal file
255
apps/api/src/modules/osm-sync/infrastructure/osm-sync.service.ts
Normal 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
22
apps/api/src/modules/osm-sync/osm-sync.module.ts
Normal file
22
apps/api/src/modules/osm-sync/osm-sync.module.ts
Normal 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 {}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
19
apps/api/src/modules/poi/poi.module.ts
Normal file
19
apps/api/src/modules/poi/poi.module.ts
Normal 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 {}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
184
apps/api/src/modules/shared/infrastructure/geo-lookup.service.ts
Normal file
184
apps/api/src/modules/shared/infrastructure/geo-lookup.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('*');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 có 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>
|
||||
);
|
||||
}
|
||||
321
apps/web/app/[locale]/(admin)/admin/osm/page.tsx
Normal file
321
apps/web/app/[locale]/(admin)/admin/osm/page.tsx
Normal 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} có 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 có 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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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))',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
67
apps/web/components/khu-cong-nghiep/osm-map-legend.tsx
Normal file
67
apps/web/components/khu-cong-nghiep/osm-map-legend.tsx
Normal 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 là dữ liệu thô từ OpenStreetMap, chưa được kiểm duyệt — thông tin có thể chưa
|
||||
chính xác hoặc thiếu.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
335
apps/web/components/khu-cong-nghiep/osm-park-bbox-map.tsx
Normal file
335
apps/web/components/khu-cong-nghiep/osm-park-bbox-map.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
151
apps/web/components/poi/nearby-poi-sidebar.tsx
Normal file
151
apps/web/components/poi/nearby-poi-sidebar.tsx
Normal 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 có 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>
|
||||
);
|
||||
}
|
||||
173
apps/web/components/poi/poi-search-filter.tsx
Normal file
173
apps/web/components/poi/poi-search-filter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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()}`;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
58
apps/web/lib/osm-sync-api.ts
Normal file
58
apps/web/lib/osm-sync-api.ts
Normal 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
Reference in New Issue
Block a user