Compare commits
47 Commits
455c959f44
...
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 | ||
|
|
489d61a27b | ||
|
|
7c5dd8d0b3 | ||
|
|
1332c759f5 | ||
|
|
abeb8fd322 | ||
|
|
89826858ac | ||
|
|
cc736e9137 | ||
|
|
a569765993 | ||
|
|
83659a4c8b | ||
|
|
3705193f97 | ||
|
|
7e655fd976 | ||
|
|
aed173adca | ||
|
|
fa3ba88f40 |
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_SECRET=<generate with: openssl rand -base64 48>
|
||||||
JWT_REFRESH_EXPIRES_IN=7d
|
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
|
# OAuth Providers
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -110,11 +119,19 @@ FRONTEND_URL=http://localhost:3000
|
|||||||
NEXT_PUBLIC_API_URL=http://localhost:3000
|
NEXT_PUBLIC_API_URL=http://localhost:3000
|
||||||
WEB_PORT=3001
|
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 (Python/FastAPI)
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
AI_SERVICE_PORT=8000
|
AI_SERVICE_PORT=8000
|
||||||
AI_SERVICE_URL=http://localhost: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=
|
CLAUDE_API_KEY=
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -221,7 +238,10 @@ SENTRY_PROJECT=
|
|||||||
# Must be exactly 64 hex characters (32 bytes).
|
# Must be exactly 64 hex characters (32 bytes).
|
||||||
# openssl rand -hex 32
|
# 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
|
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 (fast rounds for test — production uses 12+)
|
||||||
BCRYPT_ROUNDS=4
|
BCRYPT_ROUNDS=4
|
||||||
|
|
||||||
|
# Seeded admin used by E2E happy-path admin flows
|
||||||
|
SEED_DEFAULT_PASSWORD=Test@1234!
|
||||||
|
E2E_ADMIN_PHONE=0876677771
|
||||||
|
|
||||||
# OAuth (test stubs)
|
# OAuth (test stubs)
|
||||||
GOOGLE_CLIENT_ID=test-google-client-id
|
GOOGLE_CLIENT_ID=test-google-client-id
|
||||||
GOOGLE_CLIENT_SECRET=test-google-client-secret
|
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_APP_ID=TEST_ZALOPAY_APP
|
||||||
ZALOPAY_KEY1=TEST_ZALOPAY_KEY1
|
ZALOPAY_KEY1=TEST_ZALOPAY_KEY1
|
||||||
ZALOPAY_KEY2=TEST_ZALOPAY_KEY2
|
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
|
name: E2E Tests
|
||||||
needs: ci
|
needs: ci
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 45
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: postgresql://goodgo:goodgo_test_secret@localhost:5432/goodgo_test
|
CI: true
|
||||||
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
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -239,6 +170,12 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
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
|
- name: Cache Playwright browsers
|
||||||
id: playwright-cache
|
id: playwright-cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -281,3 +218,7 @@ jobs:
|
|||||||
name: playwright-traces
|
name: playwright-traces
|
||||||
path: test-results/
|
path: test-results/
|
||||||
retention-days: 7
|
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
|
|
||||||
87
.github/workflows/deploy.yml
vendored
87
.github/workflows/deploy.yml
vendored
@@ -23,6 +23,53 @@ env:
|
|||||||
REGISTRY_URL: ghcr.io/${{ github.repository_owner }}
|
REGISTRY_URL: ghcr.io/${{ github.repository_owner }}
|
||||||
|
|
||||||
jobs:
|
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:
|
build-api:
|
||||||
name: Build API Image
|
name: Build API Image
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -154,11 +201,14 @@ jobs:
|
|||||||
|
|
||||||
deploy-staging:
|
deploy-staging:
|
||||||
name: Deploy to Staging
|
name: Deploy to Staging
|
||||||
needs: [build-api, build-web, build-ai]
|
needs: [deploy-config, build-api, build-web, build-ai]
|
||||||
if: >-
|
if: >-
|
||||||
|
needs.deploy-config.outputs.staging_ready == 'true' &&
|
||||||
|
(
|
||||||
github.ref == 'refs/heads/develop' ||
|
github.ref == 'refs/heads/develop' ||
|
||||||
(github.event_name == 'push' && github.ref == 'refs/heads/master') ||
|
(github.event_name == 'push' && github.ref == 'refs/heads/master') ||
|
||||||
(github.event_name == 'workflow_dispatch' && inputs.environment == 'staging')
|
(github.event_name == 'workflow_dispatch' && inputs.environment == 'staging')
|
||||||
|
)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: staging
|
environment: staging
|
||||||
|
|
||||||
@@ -221,17 +271,17 @@ jobs:
|
|||||||
[ "\$PREV_WEB" != "none" ] && docker tag "\$PREV_WEB" goodgo-web:rollback 2>/dev/null || true
|
[ "\$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
|
[ "\$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
|
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
|
# 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 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 web
|
||||||
docker compose -f docker-compose.prod.yml up -d --no-deps --wait ai-services
|
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
|
# NOTE: docker image prune is NOT run here — it runs after smoke tests pass
|
||||||
DEPLOY_SCRIPT
|
DEPLOY_SCRIPT
|
||||||
|
|
||||||
@@ -394,8 +444,11 @@ jobs:
|
|||||||
|
|
||||||
rollback-staging:
|
rollback-staging:
|
||||||
name: Rollback Staging
|
name: Rollback Staging
|
||||||
needs: [deploy-staging, smoke-test-staging]
|
needs: [deploy-config, deploy-staging, smoke-test-staging]
|
||||||
if: failure()
|
if: >-
|
||||||
|
always() &&
|
||||||
|
needs.deploy-config.outputs.staging_ready == 'true' &&
|
||||||
|
(needs.deploy-staging.result == 'failure' || needs.smoke-test-staging.result == 'failure')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: staging
|
environment: staging
|
||||||
|
|
||||||
@@ -462,8 +515,11 @@ jobs:
|
|||||||
|
|
||||||
deploy-production:
|
deploy-production:
|
||||||
name: Deploy to Production
|
name: Deploy to Production
|
||||||
needs: [build-api, build-web, build-ai]
|
needs: [deploy-config, build-api, build-web, build-ai]
|
||||||
if: inputs.environment == 'production'
|
if: >-
|
||||||
|
github.event_name == 'workflow_dispatch' &&
|
||||||
|
inputs.environment == 'production' &&
|
||||||
|
needs.deploy-config.outputs.production_ready == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: production
|
environment: production
|
||||||
|
|
||||||
@@ -507,13 +563,15 @@ jobs:
|
|||||||
|
|
||||||
docker compose -f docker-compose.prod.yml pull api web ai-services
|
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
|
# 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 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 web
|
||||||
docker compose -f docker-compose.prod.yml up -d --no-deps --wait ai-services
|
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
|
# NOTE: docker image prune is NOT run here — it runs after smoke tests pass
|
||||||
DEPLOY_SCRIPT
|
DEPLOY_SCRIPT
|
||||||
|
|
||||||
@@ -652,8 +710,11 @@ jobs:
|
|||||||
|
|
||||||
rollback-production:
|
rollback-production:
|
||||||
name: Rollback Production
|
name: Rollback Production
|
||||||
needs: [smoke-test-production]
|
needs: [deploy-config, deploy-production, smoke-test-production]
|
||||||
if: failure()
|
if: >-
|
||||||
|
always() &&
|
||||||
|
needs.deploy-config.outputs.production_ready == 'true' &&
|
||||||
|
(needs.deploy-production.result == 'failure' || needs.smoke-test-production.result == 'failure')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: production
|
environment: production
|
||||||
|
|
||||||
|
|||||||
100
.github/workflows/e2e.yml
vendored
100
.github/workflows/e2e.yml
vendored
@@ -14,98 +14,10 @@ jobs:
|
|||||||
e2e:
|
e2e:
|
||||||
name: Playwright E2E
|
name: Playwright E2E
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 45
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
env:
|
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
|
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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -123,6 +35,12 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
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
|
- name: Cache Playwright browsers
|
||||||
id: playwright-cache
|
id: playwright-cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -165,3 +83,7 @@ jobs:
|
|||||||
name: playwright-traces
|
name: playwright-traces
|
||||||
path: test-results/
|
path: test-results/
|
||||||
retention-days: 7
|
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:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
security-events: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── Dependency Audit ─────────────────────────────────────────────
|
# ── Dependency Audit ─────────────────────────────────────────────
|
||||||
@@ -96,25 +95,8 @@ jobs:
|
|||||||
cache-from: type=gha,scope=api-scan
|
cache-from: type=gha,scope=api-scan
|
||||||
cache-to: type=gha,mode=max,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)
|
- name: Trivy table output (API)
|
||||||
uses: aquasecurity/trivy-action@0.28.0
|
uses: aquasecurity/trivy-action@v0.36.0
|
||||||
with:
|
with:
|
||||||
image-ref: "goodgo-api:scan"
|
image-ref: "goodgo-api:scan"
|
||||||
format: "table"
|
format: "table"
|
||||||
@@ -144,24 +126,8 @@ jobs:
|
|||||||
cache-from: type=gha,scope=web-scan
|
cache-from: type=gha,scope=web-scan
|
||||||
cache-to: type=gha,mode=max,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)
|
- name: Trivy table output (Web)
|
||||||
uses: aquasecurity/trivy-action@0.28.0
|
uses: aquasecurity/trivy-action@v0.36.0
|
||||||
with:
|
with:
|
||||||
image-ref: "goodgo-web:scan"
|
image-ref: "goodgo-web:scan"
|
||||||
format: "table"
|
format: "table"
|
||||||
@@ -191,24 +157,8 @@ jobs:
|
|||||||
cache-from: type=gha,scope=ai-scan
|
cache-from: type=gha,scope=ai-scan
|
||||||
cache-to: type=gha,mode=max,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)
|
- name: Trivy table output (AI)
|
||||||
uses: aquasecurity/trivy-action@0.28.0
|
uses: aquasecurity/trivy-action@v0.36.0
|
||||||
with:
|
with:
|
||||||
image-ref: "goodgo-ai:scan"
|
image-ref: "goodgo-ai:scan"
|
||||||
format: "table"
|
format: "table"
|
||||||
@@ -225,26 +175,8 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Trivy filesystem table output
|
||||||
uses: aquasecurity/trivy-action@0.28.0
|
uses: aquasecurity/trivy-action@v0.36.0
|
||||||
with:
|
with:
|
||||||
scan-type: "fs"
|
scan-type: "fs"
|
||||||
scan-ref: "."
|
scan-ref: "."
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -36,6 +36,9 @@ load-tests/results/*.json
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Redis dump (created when running redis locally without persistence config)
|
||||||
|
*.rdb
|
||||||
|
|
||||||
# personal notes / Obsidian
|
# personal notes / Obsidian
|
||||||
.obsidian/
|
.obsidian/
|
||||||
TEC/
|
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
|
if [ "${RUN_MIGRATIONS}" = "true" ]; then
|
||||||
echo "[entrypoint] Running Prisma migrations..."
|
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."
|
echo "[entrypoint] Migrations complete."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
50
apps/api/docs/observability/README.md
Normal file
50
apps/api/docs/observability/README.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Observability — Read-Model / Projector (RFC-003 Phase 0)
|
||||||
|
|
||||||
|
Grafana dashboards and wiring notes for the read-model observability stack
|
||||||
|
introduced in [GOO-192](/GOO/issues/GOO-192) under [GOO-94](/GOO/issues/GOO-94) §6 Phase 0.
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
All metrics live in the existing NestJS `metrics/` module
|
||||||
|
(`apps/api/src/modules/metrics/`) and are scraped via the standard `/metrics`
|
||||||
|
endpoint.
|
||||||
|
|
||||||
|
| Metric | Type | Labels | Purpose |
|
||||||
|
| --------------------------------------- | --------- | --------- | --------------------------------------------------------- |
|
||||||
|
| `read_model_projector_lag_seconds` | Gauge | `handler` | Seconds between latest source event and projector cursor. |
|
||||||
|
| `read_model_refresh_duration_seconds` | Histogram | `view` | Duration of read-model / materialised view refreshes. |
|
||||||
|
| `read_model_reconciliation_drift_total` | Counter | `model` | Count of drift discrepancies found during reconciliation. |
|
||||||
|
|
||||||
|
### Emit points
|
||||||
|
|
||||||
|
Inject `MetricsService` and call:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
metrics.setProjectorLag(handler, lagSeconds);
|
||||||
|
metrics.recordReadModelRefresh(view, durationSeconds);
|
||||||
|
metrics.recordReconciliationDrift(model, count?);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
- File: `read-models-dashboard.json` (Grafana schema v38).
|
||||||
|
- Import into Grafana (`Dashboards → Import → Upload JSON`), pick the Prometheus
|
||||||
|
data source.
|
||||||
|
- Variables: `handler`, `view`, `model` — derived from Prometheus label values.
|
||||||
|
- Panels:
|
||||||
|
1. Projector lag by handler (time series + thresholded)
|
||||||
|
2. Max projector lag (stat, RAG 30s / 120s)
|
||||||
|
3. Refresh duration p50/p95 by view
|
||||||
|
4. Refresh throughput (refreshes/sec) by view
|
||||||
|
5. Reconciliation drift rate by model (15m rate)
|
||||||
|
6. Total drift events in last 24h (stat, RAG 1 / 10)
|
||||||
|
|
||||||
|
## Local verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @goodgo/api dev
|
||||||
|
curl -s http://localhost:3001/metrics | grep read_model_
|
||||||
|
```
|
||||||
|
|
||||||
|
All three metric families should appear with `# HELP` / `# TYPE` headers even
|
||||||
|
before any samples are recorded.
|
||||||
77
apps/api/docs/observability/read-models-dashboard.json
Normal file
77
apps/api/docs/observability/read-models-dashboard.json
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"annotations": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"builtIn": 1,
|
||||||
|
"datasource": "-- Grafana --",
|
||||||
|
"enable": true,
|
||||||
|
"hide": true,
|
||||||
|
"iconColor": "rgba(0, 211, 255, 1)",
|
||||||
|
"name": "Annotations & Alerts",
|
||||||
|
"type": "dashboard"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"editable": true,
|
||||||
|
"graphTooltip": 1,
|
||||||
|
"id": null,
|
||||||
|
"uid": "goodgo-read-models",
|
||||||
|
"title": "GoodGo · Read-Model Observability (RFC-003 Phase 0)",
|
||||||
|
"tags": ["goodgo", "rfc-003", "read-models", "observability"],
|
||||||
|
"timezone": "browser",
|
||||||
|
"schemaVersion": 38,
|
||||||
|
"version": 1,
|
||||||
|
"refresh": "30s",
|
||||||
|
"time": { "from": "now-6h", "to": "now" },
|
||||||
|
"templating": {
|
||||||
|
"list": [
|
||||||
|
{ "name": "datasource", "type": "datasource", "query": "prometheus", "current": { "text": "Prometheus", "value": "Prometheus" } },
|
||||||
|
{ "name": "handler", "type": "query", "datasource": "${datasource}", "query": "label_values(read_model_projector_lag_seconds, handler)", "includeAll": true, "multi": true, "refresh": 2 },
|
||||||
|
{ "name": "view", "type": "query", "datasource": "${datasource}", "query": "label_values(read_model_refresh_duration_seconds_bucket, view)", "includeAll": true, "multi": true, "refresh": 2 },
|
||||||
|
{ "name": "model", "type": "query", "datasource": "${datasource}", "query": "label_values(read_model_reconciliation_drift_total, model)", "includeAll": true, "multi": true, "refresh": 2 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"id": 1, "type": "timeseries", "title": "Projector lag (seconds) — by handler",
|
||||||
|
"datasource": "${datasource}", "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "s", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 30 }, { "color": "red", "value": 120 }] } } },
|
||||||
|
"targets": [{ "expr": "read_model_projector_lag_seconds{handler=~\"$handler\"}", "legendFormat": "{{handler}}", "refId": "A" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2, "type": "stat", "title": "Max projector lag (current)",
|
||||||
|
"datasource": "${datasource}", "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "s", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 30 }, { "color": "red", "value": 120 }] } } },
|
||||||
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } },
|
||||||
|
"targets": [{ "expr": "max(read_model_projector_lag_seconds{handler=~\"$handler\"})", "refId": "A" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3, "type": "timeseries", "title": "Refresh duration p50/p95 — by view",
|
||||||
|
"datasource": "${datasource}", "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "s" } },
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "histogram_quantile(0.95, sum by (view, le) (rate(read_model_refresh_duration_seconds_bucket{view=~\"$view\"}[5m])))", "legendFormat": "p95 · {{view}}", "refId": "A" },
|
||||||
|
{ "expr": "histogram_quantile(0.50, sum by (view, le) (rate(read_model_refresh_duration_seconds_bucket{view=~\"$view\"}[5m])))", "legendFormat": "p50 · {{view}}", "refId": "B" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4, "type": "timeseries", "title": "Refresh throughput (refreshes/sec) — by view",
|
||||||
|
"datasource": "${datasource}", "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "ops" } },
|
||||||
|
"targets": [{ "expr": "sum by (view) (rate(read_model_refresh_duration_seconds_count{view=~\"$view\"}[5m]))", "legendFormat": "{{view}}", "refId": "A" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5, "type": "timeseries", "title": "Reconciliation drift rate — by model",
|
||||||
|
"datasource": "${datasource}", "gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "ops" } },
|
||||||
|
"targets": [{ "expr": "sum by (model) (rate(read_model_reconciliation_drift_total{model=~\"$model\"}[15m]))", "legendFormat": "{{model}}", "refId": "A" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6, "type": "stat", "title": "Total drift events (last 24h)",
|
||||||
|
"datasource": "${datasource}", "gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "short", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 1 }, { "color": "red", "value": 10 }] } } },
|
||||||
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } },
|
||||||
|
"targets": [{ "expr": "sum by (model) (increase(read_model_reconciliation_drift_total{model=~\"$model\"}[24h]))", "legendFormat": "{{model}}", "refId": "A" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -16,7 +16,11 @@
|
|||||||
"@anthropic-ai/sdk": "^0.89.0",
|
"@anthropic-ai/sdk": "^0.89.0",
|
||||||
"@aws-sdk/client-s3": "^3.1026.0",
|
"@aws-sdk/client-s3": "^3.1026.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1026.0",
|
"@aws-sdk/s3-request-presigner": "^3.1026.0",
|
||||||
|
"@bull-board/api": "^7.0.0",
|
||||||
|
"@bull-board/express": "^7.0.0",
|
||||||
|
"@bull-board/nestjs": "^7.0.0",
|
||||||
"@goodgo/mcp-servers": "workspace:*",
|
"@goodgo/mcp-servers": "workspace:*",
|
||||||
|
"@goodgo/contracts-events": "workspace:*",
|
||||||
"@nest-lab/throttler-storage-redis": "^1.2.0",
|
"@nest-lab/throttler-storage-redis": "^1.2.0",
|
||||||
"@nestjs/bullmq": "^11.0.4",
|
"@nestjs/bullmq": "^11.0.4",
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
@@ -49,6 +53,7 @@
|
|||||||
"handlebars": "^4.7.9",
|
"handlebars": "^4.7.9",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"ioredis": "^5.4.0",
|
"ioredis": "^5.4.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^8.0.5",
|
||||||
"otplib": "^13.4.0",
|
"otplib": "^13.4.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
@@ -75,6 +80,7 @@
|
|||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^25.5.2",
|
"@types/node": "^25.5.2",
|
||||||
"@types/nodemailer": "^8.0.0",
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/passport-google-oauth20": "^2.0.17",
|
"@types/passport-google-oauth20": "^2.0.17",
|
||||||
|
|||||||
@@ -20,8 +20,11 @@ import { McpIntegrationModule } from '@modules/mcp';
|
|||||||
import { MessagingModule } from '@modules/messaging';
|
import { MessagingModule } from '@modules/messaging';
|
||||||
import { HttpMetricsInterceptor, MetricsModule } from '@modules/metrics';
|
import { HttpMetricsInterceptor, MetricsModule } from '@modules/metrics';
|
||||||
import { NotificationsModule } from '@modules/notifications';
|
import { NotificationsModule } from '@modules/notifications';
|
||||||
|
import { OsmSyncModule } from '@modules/osm-sync/osm-sync.module';
|
||||||
import { PaymentsModule } from '@modules/payments';
|
import { PaymentsModule } from '@modules/payments';
|
||||||
|
import { PoiModule } from '@modules/poi/poi.module';
|
||||||
import { ProjectsModule } from '@modules/projects';
|
import { ProjectsModule } from '@modules/projects';
|
||||||
|
import { QueuesModule } from '@modules/queues/queues.module';
|
||||||
import { ReportsModule } from '@modules/reports';
|
import { ReportsModule } from '@modules/reports';
|
||||||
import { ReviewsModule } from '@modules/reviews';
|
import { ReviewsModule } from '@modules/reviews';
|
||||||
import { SearchModule } from '@modules/search';
|
import { SearchModule } from '@modules/search';
|
||||||
@@ -57,11 +60,14 @@ import { AppController } from './app.controller';
|
|||||||
FavoritesModule,
|
FavoritesModule,
|
||||||
SearchModule,
|
SearchModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
OsmSyncModule,
|
||||||
PaymentsModule,
|
PaymentsModule,
|
||||||
|
PoiModule,
|
||||||
SubscriptionsModule,
|
SubscriptionsModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
AnalyticsModule,
|
AnalyticsModule,
|
||||||
MetricsModule,
|
MetricsModule,
|
||||||
|
MetricsModule.withQueueMetrics(),
|
||||||
McpIntegrationModule,
|
McpIntegrationModule,
|
||||||
MessagingModule,
|
MessagingModule,
|
||||||
ReportsModule,
|
ReportsModule,
|
||||||
@@ -69,6 +75,9 @@ import { AppController } from './app.controller';
|
|||||||
IndustrialModule,
|
IndustrialModule,
|
||||||
TransferModule,
|
TransferModule,
|
||||||
|
|
||||||
|
// ── Bull Board UI (RFC-004 Phase 3 WS3b) ──
|
||||||
|
QueuesModule,
|
||||||
|
|
||||||
// ── Rate Limiting ──
|
// ── Rate Limiting ──
|
||||||
// Default: 60 requests per 60 seconds per IP
|
// Default: 60 requests per 60 seconds per IP
|
||||||
// Override per-route with @Throttle() decorator
|
// Override per-route with @Throttle() decorator
|
||||||
@@ -143,6 +152,8 @@ export class AppModule implements NestModule {
|
|||||||
{ path: 'health/(.*)', method: RequestMethod.GET },
|
{ path: 'health/(.*)', method: RequestMethod.GET },
|
||||||
{ path: 'api/v1/web-vitals', method: RequestMethod.POST }, // sendBeacon cannot send CSRF headers
|
{ 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: 'web-vitals', method: RequestMethod.POST }, // middleware exclude uses controller-relative path
|
||||||
|
{ path: 'api/v1/admin/queues', method: RequestMethod.ALL },
|
||||||
|
{ path: 'api/v1/admin/queues/(.*)', method: RequestMethod.ALL },
|
||||||
)
|
)
|
||||||
.forRoutes('*');
|
.forRoutes('*');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const isTest = process.env['NODE_ENV'] === 'test';
|
|||||||
const integrations: any[] = [];
|
const integrations: any[] = [];
|
||||||
if (!isTest) {
|
if (!isTest) {
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const { nodeProfilingIntegration } = require('@sentry/profiling-node') as typeof import('@sentry/profiling-node');
|
const { nodeProfilingIntegration } = require('@sentry/profiling-node') as typeof import('@sentry/profiling-node');
|
||||||
integrations.push(nodeProfilingIntegration());
|
integrations.push(nodeProfilingIntegration());
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -33,10 +33,10 @@ import { SystemSettingsService } from './application/services/system-settings.se
|
|||||||
import { ADMIN_QUERY_REPOSITORY } from './domain/repositories/admin-query.repository';
|
import { ADMIN_QUERY_REPOSITORY } from './domain/repositories/admin-query.repository';
|
||||||
import { AUDIT_LOG_REPOSITORY } from './domain/repositories/audit-log.repository';
|
import { AUDIT_LOG_REPOSITORY } from './domain/repositories/audit-log.repository';
|
||||||
import { MODERATION_AUDIT_LOG_REPOSITORY } from './domain/repositories/moderation-audit-log.repository';
|
import { MODERATION_AUDIT_LOG_REPOSITORY } from './domain/repositories/moderation-audit-log.repository';
|
||||||
|
import { SystemSettingsAiConfigProvider } from './infrastructure/adapters/system-settings-ai-config.provider';
|
||||||
import { PrismaAdminQueryRepository } from './infrastructure/repositories/prisma-admin-query.repository';
|
import { PrismaAdminQueryRepository } from './infrastructure/repositories/prisma-admin-query.repository';
|
||||||
import { PrismaAuditLogRepository } from './infrastructure/repositories/prisma-audit-log.repository';
|
import { PrismaAuditLogRepository } from './infrastructure/repositories/prisma-audit-log.repository';
|
||||||
import { PrismaModerationAuditLogRepository } from './infrastructure/repositories/prisma-moderation-audit-log.repository';
|
import { PrismaModerationAuditLogRepository } from './infrastructure/repositories/prisma-moderation-audit-log.repository';
|
||||||
import { SystemSettingsAiConfigProvider } from './infrastructure/adapters/system-settings-ai-config.provider';
|
|
||||||
import { AdminModerationAuditController } from './presentation/controllers/admin-moderation-audit.controller';
|
import { AdminModerationAuditController } from './presentation/controllers/admin-moderation-audit.controller';
|
||||||
import { AdminModerationController } from './presentation/controllers/admin-moderation.controller';
|
import { AdminModerationController } from './presentation/controllers/admin-moderation.controller';
|
||||||
import { AdminController } from './presentation/controllers/admin.controller';
|
import { AdminController } from './presentation/controllers/admin.controller';
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { ConflictException, Inject } from '@nestjs/common';
|
|||||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { createId } from '@paralleldrive/cuid2';
|
import { createId } from '@paralleldrive/cuid2';
|
||||||
import { PrismaService, ValidationException } from '@modules/shared';
|
import { PrismaService, ValidationException } from '@modules/shared';
|
||||||
import { USER_REPOSITORY, type IUserRepository } from '../../../../auth/domain/repositories/user.repository';
|
|
||||||
import { UserEntity } from '../../../../auth/domain/entities/user.entity';
|
import { UserEntity } from '../../../../auth/domain/entities/user.entity';
|
||||||
|
import { USER_REPOSITORY, type IUserRepository } from '../../../../auth/domain/repositories/user.repository';
|
||||||
import { Email } from '../../../../auth/domain/value-objects/email.vo';
|
import { Email } from '../../../../auth/domain/value-objects/email.vo';
|
||||||
import { HashedPassword } from '../../../../auth/domain/value-objects/hashed-password.vo';
|
import { HashedPassword } from '../../../../auth/domain/value-objects/hashed-password.vo';
|
||||||
import { Phone } from '../../../../auth/domain/value-objects/phone.vo';
|
import { Phone } from '../../../../auth/domain/value-objects/phone.vo';
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { ConflictException, Inject } from '@nestjs/common';
|
|||||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { createId } from '@paralleldrive/cuid2';
|
import { createId } from '@paralleldrive/cuid2';
|
||||||
import { PrismaService, ValidationException } from '@modules/shared';
|
import { PrismaService, ValidationException } from '@modules/shared';
|
||||||
import { USER_REPOSITORY, type IUserRepository } from '../../../../auth/domain/repositories/user.repository';
|
|
||||||
import { UserEntity } from '../../../../auth/domain/entities/user.entity';
|
import { UserEntity } from '../../../../auth/domain/entities/user.entity';
|
||||||
|
import { USER_REPOSITORY, type IUserRepository } from '../../../../auth/domain/repositories/user.repository';
|
||||||
import { Email } from '../../../../auth/domain/value-objects/email.vo';
|
import { Email } from '../../../../auth/domain/value-objects/email.vo';
|
||||||
import { HashedPassword } from '../../../../auth/domain/value-objects/hashed-password.vo';
|
import { HashedPassword } from '../../../../auth/domain/value-objects/hashed-password.vo';
|
||||||
import { Phone } from '../../../../auth/domain/value-objects/phone.vo';
|
import { Phone } from '../../../../auth/domain/value-objects/phone.vo';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { type PrismaService } from '@modules/shared';
|
import { type PrismaService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
type DashboardStats,
|
type DashboardStats,
|
||||||
@@ -80,7 +81,12 @@ export async function getRevenueStats(
|
|||||||
return cached.data;
|
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[]>`
|
const rows = await prisma.$queryRaw<RevenueRawRow[]>`
|
||||||
SELECT
|
SELECT
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import { RejectKycCommand } from '../../application/commands/reject-kyc/reject-k
|
|||||||
import { type RejectKycResult } from '../../application/commands/reject-kyc/reject-kyc.handler';
|
import { type RejectKycResult } from '../../application/commands/reject-kyc/reject-kyc.handler';
|
||||||
import { RejectListingCommand } from '../../application/commands/reject-listing/reject-listing.command';
|
import { RejectListingCommand } from '../../application/commands/reject-listing/reject-listing.command';
|
||||||
import { type RejectListingResult } from '../../application/commands/reject-listing/reject-listing.handler';
|
import { type RejectListingResult } from '../../application/commands/reject-listing/reject-listing.handler';
|
||||||
|
import type { FlaggedListingsResult } from '../../application/queries/get-flagged-listings/get-flagged-listings.handler';
|
||||||
|
import { GetFlaggedListingsQuery } from '../../application/queries/get-flagged-listings/get-flagged-listings.query';
|
||||||
import { GetKycQueueQuery } from '../../application/queries/get-kyc-queue/get-kyc-queue.query';
|
import { GetKycQueueQuery } from '../../application/queries/get-kyc-queue/get-kyc-queue.query';
|
||||||
import { GetModerationQueueQuery } from '../../application/queries/get-moderation-queue/get-moderation-queue.query';
|
import { GetModerationQueueQuery } from '../../application/queries/get-moderation-queue/get-moderation-queue.query';
|
||||||
import {
|
import {
|
||||||
@@ -37,8 +39,6 @@ import { ApproveListingDto } from '../dto/approve-listing.dto';
|
|||||||
import { BulkModerateDto } from '../dto/bulk-moderate.dto';
|
import { BulkModerateDto } from '../dto/bulk-moderate.dto';
|
||||||
import { RejectKycDto } from '../dto/reject-kyc.dto';
|
import { RejectKycDto } from '../dto/reject-kyc.dto';
|
||||||
import { RejectListingDto } from '../dto/reject-listing.dto';
|
import { RejectListingDto } from '../dto/reject-listing.dto';
|
||||||
import { GetFlaggedListingsQuery } from '../../application/queries/get-flagged-listings/get-flagged-listings.query';
|
|
||||||
import type { FlaggedListingsResult } from '../../application/queries/get-flagged-listings/get-flagged-listings.handler';
|
|
||||||
|
|
||||||
@ApiTags('admin')
|
@ApiTags('admin')
|
||||||
@ApiBearerAuth('JWT')
|
@ApiBearerAuth('JWT')
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ import { type ProvisionParkOperatorResult } from '../../application/commands/pro
|
|||||||
import { UpdateAiSettingsCommand } from '../../application/commands/update-ai-settings/update-ai-settings.command';
|
import { UpdateAiSettingsCommand } from '../../application/commands/update-ai-settings/update-ai-settings.command';
|
||||||
import { UpdateUserStatusCommand } from '../../application/commands/update-user-status/update-user-status.command';
|
import { UpdateUserStatusCommand } from '../../application/commands/update-user-status/update-user-status.command';
|
||||||
import { type UpdateUserStatusResult } from '../../application/commands/update-user-status/update-user-status.handler';
|
import { type UpdateUserStatusResult } from '../../application/commands/update-user-status/update-user-status.handler';
|
||||||
import { GetAiSettingsQuery } from '../../application/queries/get-ai-settings/get-ai-settings.query';
|
|
||||||
import { type AiSettingsDto } from '../../application/queries/get-ai-settings/get-ai-settings.handler';
|
import { type AiSettingsDto } from '../../application/queries/get-ai-settings/get-ai-settings.handler';
|
||||||
|
import { GetAiSettingsQuery } from '../../application/queries/get-ai-settings/get-ai-settings.query';
|
||||||
import { GetAuditLogsQuery } from '../../application/queries/get-audit-logs/get-audit-logs.query';
|
import { GetAuditLogsQuery } from '../../application/queries/get-audit-logs/get-audit-logs.query';
|
||||||
import { GetDashboardStatsQuery } from '../../application/queries/get-dashboard-stats/get-dashboard-stats.query';
|
import { GetDashboardStatsQuery } from '../../application/queries/get-dashboard-stats/get-dashboard-stats.query';
|
||||||
import { GetRevenueStatsQuery } from '../../application/queries/get-revenue-stats/get-revenue-stats.query';
|
import { GetRevenueStatsQuery } from '../../application/queries/get-revenue-stats/get-revenue-stats.query';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { forwardRef, Module } from '@nestjs/common';
|
import { forwardRef, Module } from '@nestjs/common';
|
||||||
import { CqrsModule } from '@nestjs/cqrs';
|
import { CqrsModule } from '@nestjs/cqrs';
|
||||||
import { makeCounterProvider, makeHistogramProvider } from '@willsoto/nestjs-prometheus';
|
import { makeCounterProvider, makeHistogramProvider } from '@willsoto/nestjs-prometheus';
|
||||||
|
import { AdminModule } from '@modules/admin';
|
||||||
import { ListingsModule } from '@modules/listings';
|
import { ListingsModule } from '@modules/listings';
|
||||||
import { ProjectsModule } from '@modules/projects';
|
import { ProjectsModule } from '@modules/projects';
|
||||||
import { GenerateReportHandler } from './application/commands/generate-report/generate-report.handler';
|
import { GenerateReportHandler } from './application/commands/generate-report/generate-report.handler';
|
||||||
@@ -8,21 +9,21 @@ import { TrackEventHandler } from './application/commands/track-event/track-even
|
|||||||
import { UpdateMarketIndexHandler } from './application/commands/update-market-index/update-market-index.handler';
|
import { UpdateMarketIndexHandler } from './application/commands/update-market-index/update-market-index.handler';
|
||||||
import { ListingCreatedModerationHandler } from './application/event-handlers/listing-created-moderation.handler';
|
import { ListingCreatedModerationHandler } from './application/event-handlers/listing-created-moderation.handler';
|
||||||
import { BatchValuationHandler } from './application/queries/batch-valuation/batch-valuation.handler';
|
import { BatchValuationHandler } from './application/queries/batch-valuation/batch-valuation.handler';
|
||||||
import { IndustrialValuationHandler } from './application/queries/industrial-valuation/industrial-valuation.handler';
|
|
||||||
import { GetDistrictStatsHandler } from './application/queries/get-district-stats/get-district-stats.handler';
|
import { GetDistrictStatsHandler } from './application/queries/get-district-stats/get-district-stats.handler';
|
||||||
import { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap.handler';
|
import { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap.handler';
|
||||||
import { GetListingAiAdviceHandler } from './application/queries/get-listing-ai-advice/get-listing-ai-advice.handler';
|
import { GetListingAiAdviceHandler } from './application/queries/get-listing-ai-advice/get-listing-ai-advice.handler';
|
||||||
import { GetListingVolumeWardHandler } from './application/queries/get-listing-volume-ward/get-listing-volume-ward.handler';
|
import { GetListingVolumeWardHandler } from './application/queries/get-listing-volume-ward/get-listing-volume-ward.handler';
|
||||||
import { GetMarketReportHandler } from './application/queries/get-market-report/get-market-report.handler';
|
|
||||||
import { GetMarketHistoryHandler } from './application/queries/get-market-history/get-market-history.handler';
|
import { GetMarketHistoryHandler } from './application/queries/get-market-history/get-market-history.handler';
|
||||||
|
import { GetMarketReportHandler } from './application/queries/get-market-report/get-market-report.handler';
|
||||||
import { GetMarketSnapshotHandler } from './application/queries/get-market-snapshot/get-market-snapshot.handler';
|
import { GetMarketSnapshotHandler } from './application/queries/get-market-snapshot/get-market-snapshot.handler';
|
||||||
import { GetPriceMoversHandler } from './application/queries/get-price-movers/get-price-movers.handler';
|
|
||||||
import { GetTrendingAreasHandler } from './application/queries/get-trending-areas/get-trending-areas.handler';
|
|
||||||
import { GetProjectAiAdviceHandler } from './application/queries/get-project-ai-advice/get-project-ai-advice.handler';
|
|
||||||
import { GetNearbyPOIsHandler } from './application/queries/get-nearby-pois/get-nearby-pois.handler';
|
import { GetNearbyPOIsHandler } from './application/queries/get-nearby-pois/get-nearby-pois.handler';
|
||||||
import { GetNeighborhoodScoreHandler } from './application/queries/get-neighborhood-score/get-neighborhood-score.handler';
|
import { GetNeighborhoodScoreHandler } from './application/queries/get-neighborhood-score/get-neighborhood-score.handler';
|
||||||
|
import { GetPriceMoversHandler } from './application/queries/get-price-movers/get-price-movers.handler';
|
||||||
import { GetPriceTrendHandler } from './application/queries/get-price-trend/get-price-trend.handler';
|
import { GetPriceTrendHandler } from './application/queries/get-price-trend/get-price-trend.handler';
|
||||||
|
import { GetProjectAiAdviceHandler } from './application/queries/get-project-ai-advice/get-project-ai-advice.handler';
|
||||||
|
import { GetTrendingAreasHandler } from './application/queries/get-trending-areas/get-trending-areas.handler';
|
||||||
import { GetValuationHandler } from './application/queries/get-valuation/get-valuation.handler';
|
import { GetValuationHandler } from './application/queries/get-valuation/get-valuation.handler';
|
||||||
|
import { IndustrialValuationHandler } from './application/queries/industrial-valuation/industrial-valuation.handler';
|
||||||
import { PredictValuationHandler } from './application/queries/predict-valuation/predict-valuation.handler';
|
import { PredictValuationHandler } from './application/queries/predict-valuation/predict-valuation.handler';
|
||||||
import { ValuationComparisonHandler } from './application/queries/valuation-comparison/valuation-comparison.handler';
|
import { ValuationComparisonHandler } from './application/queries/valuation-comparison/valuation-comparison.handler';
|
||||||
import { ValuationExplanationHandler } from './application/queries/valuation-explanation/valuation-explanation.handler';
|
import { ValuationExplanationHandler } from './application/queries/valuation-explanation/valuation-explanation.handler';
|
||||||
@@ -36,17 +37,17 @@ import { PrismaValuationRepository } from './infrastructure/repositories/prisma-
|
|||||||
import { AI_SERVICE_CLIENT, AiServiceClient } from './infrastructure/services/ai-service.client';
|
import { AI_SERVICE_CLIENT, AiServiceClient } from './infrastructure/services/ai-service.client';
|
||||||
import { HttpAVMService } from './infrastructure/services/http-avm.service';
|
import { HttpAVMService } from './infrastructure/services/http-avm.service';
|
||||||
import { MarketIndexCronService } from './infrastructure/services/market-index-cron.service';
|
import { MarketIndexCronService } from './infrastructure/services/market-index-cron.service';
|
||||||
|
import {
|
||||||
|
HttpNeighborhoodScoreService,
|
||||||
|
PrismaNeighborhoodScoreService,
|
||||||
|
} from './infrastructure/services/neighborhood-score.service';
|
||||||
|
import { PrismaAVMService } from './infrastructure/services/prisma-avm.service';
|
||||||
import {
|
import {
|
||||||
RefreshMaterializedViewCronService,
|
RefreshMaterializedViewCronService,
|
||||||
MATVIEW_REFRESH_TOTAL,
|
MATVIEW_REFRESH_TOTAL,
|
||||||
MATVIEW_REFRESH_DURATION,
|
MATVIEW_REFRESH_DURATION,
|
||||||
MATVIEW_REFRESH_ERRORS,
|
MATVIEW_REFRESH_ERRORS,
|
||||||
} from './infrastructure/services/refresh-materialized-view-cron.service';
|
} from './infrastructure/services/refresh-materialized-view-cron.service';
|
||||||
import {
|
|
||||||
HttpNeighborhoodScoreService,
|
|
||||||
PrismaNeighborhoodScoreService,
|
|
||||||
} from './infrastructure/services/neighborhood-score.service';
|
|
||||||
import { PrismaAVMService } from './infrastructure/services/prisma-avm.service';
|
|
||||||
import { AnalyticsController } from './presentation/controllers/analytics.controller';
|
import { AnalyticsController } from './presentation/controllers/analytics.controller';
|
||||||
import { AvmController } from './presentation/controllers/avm.controller';
|
import { AvmController } from './presentation/controllers/avm.controller';
|
||||||
|
|
||||||
@@ -84,7 +85,12 @@ const EventHandlers = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
@Module({
|
@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],
|
controllers: [AnalyticsController, AvmController],
|
||||||
providers: [
|
providers: [
|
||||||
// AI service client
|
// AI service client
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { InternalServerErrorException } from '@nestjs/common';
|
import { InternalServerErrorException } from '@nestjs/common';
|
||||||
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
|
||||||
import { DomainException } from '@modules/shared';
|
import { DomainException } from '@modules/shared';
|
||||||
|
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||||
import {
|
import {
|
||||||
type IAVMService,
|
type IAVMService,
|
||||||
type BatchValuationResult,
|
type BatchValuationResult,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { InternalServerErrorException } from '@nestjs/common';
|
import { InternalServerErrorException } from '@nestjs/common';
|
||||||
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
|
||||||
import { type PrismaService } from '@modules/shared';
|
import { type PrismaService } from '@modules/shared';
|
||||||
|
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||||
import { GetMarketSnapshotHandler } from '../queries/get-market-snapshot/get-market-snapshot.handler';
|
import { GetMarketSnapshotHandler } from '../queries/get-market-snapshot/get-market-snapshot.handler';
|
||||||
import { GetMarketSnapshotQuery } from '../queries/get-market-snapshot/get-market-snapshot.query';
|
import { GetMarketSnapshotQuery } from '../queries/get-market-snapshot/get-market-snapshot.query';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { type INeighborhoodScoreService, type NeighborhoodScoreResult } from '../../domain/services/neighborhood-score.service';
|
|
||||||
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||||
|
import { type INeighborhoodScoreService, type NeighborhoodScoreResult } from '../../domain/services/neighborhood-score.service';
|
||||||
import { GetNeighborhoodScoreHandler } from '../queries/get-neighborhood-score/get-neighborhood-score.handler';
|
import { GetNeighborhoodScoreHandler } from '../queries/get-neighborhood-score/get-neighborhood-score.handler';
|
||||||
import { GetNeighborhoodScoreQuery } from '../queries/get-neighborhood-score/get-neighborhood-score.query';
|
import { GetNeighborhoodScoreQuery } from '../queries/get-neighborhood-score/get-neighborhood-score.query';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { InternalServerErrorException } from '@nestjs/common';
|
import { InternalServerErrorException } from '@nestjs/common';
|
||||||
import { type CacheService, type PrismaService } from '@modules/shared';
|
import { type CacheService, type PrismaService, DomainException } from '@modules/shared';
|
||||||
import { DomainException } from '@modules/shared';
|
|
||||||
import { type IAVMService, type ValuationResult } from '../../domain/services/avm-service';
|
import { type IAVMService, type ValuationResult } from '../../domain/services/avm-service';
|
||||||
import { ValuationComparisonHandler } from '../queries/valuation-comparison/valuation-comparison.handler';
|
import { ValuationComparisonHandler } from '../queries/valuation-comparison/valuation-comparison.handler';
|
||||||
import { ValuationComparisonQuery } from '../queries/valuation-comparison/valuation-comparison.query';
|
import { ValuationComparisonQuery } from '../queries/valuation-comparison/valuation-comparison.query';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { InternalServerErrorException } from '@nestjs/common';
|
import { InternalServerErrorException } from '@nestjs/common';
|
||||||
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
|
||||||
import { DomainException, NotFoundException } from '@modules/shared';
|
import { DomainException, NotFoundException } from '@modules/shared';
|
||||||
|
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||||
import { ValuationEntity } from '../../domain/entities/valuation.entity';
|
import { ValuationEntity } from '../../domain/entities/valuation.entity';
|
||||||
import { type IValuationRepository } from '../../domain/repositories/valuation.repository';
|
import { type IValuationRepository } from '../../domain/repositories/valuation.repository';
|
||||||
import { ValuationExplanationHandler } from '../queries/valuation-explanation/valuation-explanation.handler';
|
import { ValuationExplanationHandler } from '../queries/valuation-explanation/valuation-explanation.handler';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { InternalServerErrorException } from '@nestjs/common';
|
import { InternalServerErrorException } from '@nestjs/common';
|
||||||
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
|
||||||
import { DomainException } from '@modules/shared';
|
import { DomainException } from '@modules/shared';
|
||||||
|
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||||
import { ValuationEntity } from '../../domain/entities/valuation.entity';
|
import { ValuationEntity } from '../../domain/entities/valuation.entity';
|
||||||
import { type IValuationRepository } from '../../domain/repositories/valuation.repository';
|
import { type IValuationRepository } from '../../domain/repositories/valuation.repository';
|
||||||
import { ValuationHistoryHandler } from '../queries/valuation-history/valuation-history.handler';
|
import { ValuationHistoryHandler } from '../queries/valuation-history/valuation-history.handler';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
import { EventsHandler, type IEventHandler, CommandBus } from '@nestjs/cqrs';
|
import { EventsHandler, type IEventHandler, CommandBus } from '@nestjs/cqrs';
|
||||||
|
import { ModerateListingCommand } from '@modules/listings';
|
||||||
import { ListingCreatedEvent } from '@modules/listings/domain/events/listing-created.event';
|
import { ListingCreatedEvent } from '@modules/listings/domain/events/listing-created.event';
|
||||||
import { ModerateListingCommand } from '@modules/listings/application/commands/moderate-listing/moderate-listing.command';
|
|
||||||
import { PrismaService, LoggerService } from '@modules/shared';
|
import { PrismaService, LoggerService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
AI_SERVICE_CLIENT,
|
AI_SERVICE_CLIENT,
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { HttpStatus, Inject } from '@nestjs/common';
|
import { HttpStatus, Inject } from '@nestjs/common';
|
||||||
import { QueryBus, QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
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/domain/repositories/listing.repository';
|
||||||
import {
|
import {
|
||||||
AI_CONFIG_PROVIDER,
|
AI_CONFIG_PROVIDER,
|
||||||
DomainException,
|
DomainException,
|
||||||
@@ -7,18 +16,8 @@ import {
|
|||||||
type IAIConfigProvider,
|
type IAIConfigProvider,
|
||||||
LoggerService,
|
LoggerService,
|
||||||
} from '@modules/shared';
|
} from '@modules/shared';
|
||||||
import {
|
|
||||||
LISTING_REPOSITORY,
|
|
||||||
type IListingRepository,
|
|
||||||
} from '@modules/listings/domain/repositories/listing.repository';
|
|
||||||
import { type ListingDetailData } from '../../../../listings/domain/repositories/listing-read.dto';
|
import { type ListingDetailData } from '../../../../listings/domain/repositories/listing-read.dto';
|
||||||
import {
|
|
||||||
type NearbyPOIDto,
|
|
||||||
type NearbyPOIsResultDto,
|
|
||||||
} from '../get-nearby-pois/get-nearby-pois.handler';
|
|
||||||
import { GetNearbyPOIsQuery } from '../get-nearby-pois/get-nearby-pois.query';
|
|
||||||
import { type NeighborhoodScoreResult } from '../../../domain/services/neighborhood-score.service';
|
import { type NeighborhoodScoreResult } from '../../../domain/services/neighborhood-score.service';
|
||||||
import { GetNeighborhoodScoreQuery } from '../get-neighborhood-score/get-neighborhood-score.query';
|
|
||||||
import {
|
import {
|
||||||
asInt,
|
asInt,
|
||||||
asString,
|
asString,
|
||||||
@@ -28,6 +27,12 @@ import {
|
|||||||
jsonShapeError,
|
jsonShapeError,
|
||||||
parseJsonObject,
|
parseJsonObject,
|
||||||
} from '../_shared/ai-json-client';
|
} from '../_shared/ai-json-client';
|
||||||
|
import {
|
||||||
|
type NearbyPOIDto,
|
||||||
|
type NearbyPOIsResultDto,
|
||||||
|
} from '../get-nearby-pois/get-nearby-pois.handler';
|
||||||
|
import { GetNearbyPOIsQuery } from '../get-nearby-pois/get-nearby-pois.query';
|
||||||
|
import { GetNeighborhoodScoreQuery } from '../get-neighborhood-score/get-neighborhood-score.query';
|
||||||
import { GetListingAiAdviceQuery } from './get-listing-ai-advice.query';
|
import { GetListingAiAdviceQuery } from './get-listing-ai-advice.query';
|
||||||
|
|
||||||
/** Shape returned by Anthropic (parsed from first content block). */
|
/** Shape returned by Anthropic (parsed from first content block). */
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { InternalServerErrorException } from '@nestjs/common';
|
import { InternalServerErrorException } from '@nestjs/common';
|
||||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
import { DomainException, CacheService, CachePrefix, CacheTTL, LoggerService, PrismaService } from '@modules/shared';
|
|
||||||
import { type PropertyType, ListingStatus, Prisma } from '@prisma/client';
|
import { type PropertyType, ListingStatus, Prisma } from '@prisma/client';
|
||||||
|
import { DomainException, CacheService, CachePrefix, CacheTTL, LoggerService, PrismaService } from '@modules/shared';
|
||||||
import { GetMarketSnapshotQuery } from './get-market-snapshot.query';
|
import { GetMarketSnapshotQuery } from './get-market-snapshot.query';
|
||||||
|
|
||||||
export interface PriceChangePct {
|
export interface PriceChangePct {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { HttpStatus, Inject } from '@nestjs/common';
|
import { HttpStatus, Inject } from '@nestjs/common';
|
||||||
import { QueryBus, QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
import { QueryBus, QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
PROJECT_REPOSITORY,
|
||||||
|
type IProjectRepository,
|
||||||
|
type ProjectDetailData,
|
||||||
|
} from '@modules/projects';
|
||||||
import {
|
import {
|
||||||
AI_CONFIG_PROVIDER,
|
AI_CONFIG_PROVIDER,
|
||||||
DomainException,
|
DomainException,
|
||||||
@@ -7,26 +12,19 @@ import {
|
|||||||
type IAIConfigProvider,
|
type IAIConfigProvider,
|
||||||
LoggerService,
|
LoggerService,
|
||||||
} from '@modules/shared';
|
} from '@modules/shared';
|
||||||
import {
|
import { type NeighborhoodScoreResult } from '../../../domain/services/neighborhood-score.service';
|
||||||
PROJECT_REPOSITORY,
|
import { type AnthropicUsage,
|
||||||
type IProjectRepository,
|
|
||||||
type ProjectDetailData,
|
|
||||||
} from '@modules/projects';
|
|
||||||
import { type AnthropicUsage } from '../_shared/ai-json-client';
|
|
||||||
import {
|
|
||||||
asString,
|
asString,
|
||||||
asStringArray,
|
asStringArray,
|
||||||
callAnthropicJson,
|
callAnthropicJson,
|
||||||
isRecord,
|
isRecord,
|
||||||
jsonShapeError,
|
jsonShapeError,
|
||||||
parseJsonObject,
|
parseJsonObject } from '../_shared/ai-json-client';
|
||||||
} from '../_shared/ai-json-client';
|
|
||||||
import {
|
import {
|
||||||
type NearbyPOIDto,
|
type NearbyPOIDto,
|
||||||
type NearbyPOIsResultDto,
|
type NearbyPOIsResultDto,
|
||||||
} from '../get-nearby-pois/get-nearby-pois.handler';
|
} from '../get-nearby-pois/get-nearby-pois.handler';
|
||||||
import { GetNearbyPOIsQuery } from '../get-nearby-pois/get-nearby-pois.query';
|
import { GetNearbyPOIsQuery } from '../get-nearby-pois/get-nearby-pois.query';
|
||||||
import { type NeighborhoodScoreResult } from '../../../domain/services/neighborhood-score.service';
|
|
||||||
import { GetNeighborhoodScoreQuery } from '../get-neighborhood-score/get-neighborhood-score.query';
|
import { GetNeighborhoodScoreQuery } from '../get-neighborhood-score/get-neighborhood-score.query';
|
||||||
import { GetProjectAiAdviceQuery } from './get-project-ai-advice.query';
|
import { GetProjectAiAdviceQuery } from './get-project-ai-advice.query';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { InternalServerErrorException } from '@nestjs/common';
|
||||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
import { DomainException, CacheService, CachePrefix, CacheTTL, Cacheable, LoggerService, PrismaService } from '@modules/shared';
|
import { DomainException, CacheService, CachePrefix, CacheTTL, Cacheable, LoggerService, PrismaService } from '@modules/shared';
|
||||||
import { GetTrendingAreasQuery } from './get-trending-areas.query';
|
import { GetTrendingAreasQuery } from './get-trending-areas.query';
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ import {
|
|||||||
PrismaNeighborhoodScoreService,
|
PrismaNeighborhoodScoreService,
|
||||||
} from '../services/neighborhood-score.service';
|
} from '../services/neighborhood-score.service';
|
||||||
|
|
||||||
|
// Helper: build the flat $queryRaw row list that countPOIs expects.
|
||||||
|
function makePoiRows(counts: Record<string, number>) {
|
||||||
|
return Object.entries(counts).map(([type, n]) => ({ type, count: BigInt(n) }));
|
||||||
|
}
|
||||||
|
|
||||||
describe('NeighborhoodScoreServiceImpl', () => {
|
describe('NeighborhoodScoreServiceImpl', () => {
|
||||||
let service: NeighborhoodScoreServiceImpl;
|
let service: NeighborhoodScoreServiceImpl;
|
||||||
let mockPrisma: {
|
let mockPrisma: {
|
||||||
neighborhoodScore: { findUnique: ReturnType<typeof vi.fn>; upsert: ReturnType<typeof vi.fn> };
|
neighborhoodScore: { findUnique: ReturnType<typeof vi.fn>; upsert: ReturnType<typeof vi.fn> };
|
||||||
pOI: { count: ReturnType<typeof vi.fn> };
|
$queryRaw: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
let mockLogger: { log: ReturnType<typeof vi.fn> };
|
let mockLogger: { log: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
@@ -18,7 +23,7 @@ describe('NeighborhoodScoreServiceImpl', () => {
|
|||||||
findUnique: vi.fn(),
|
findUnique: vi.fn(),
|
||||||
upsert: vi.fn(),
|
upsert: vi.fn(),
|
||||||
},
|
},
|
||||||
pOI: { count: vi.fn() },
|
$queryRaw: vi.fn(),
|
||||||
};
|
};
|
||||||
mockLogger = { log: vi.fn() };
|
mockLogger = { log: vi.fn() };
|
||||||
|
|
||||||
@@ -60,44 +65,45 @@ describe('NeighborhoodScoreServiceImpl', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('calculateAndSave', () => {
|
describe('calculateAndSave', () => {
|
||||||
it('calculates scores from POI counts and upserts', async () => {
|
it('issues exactly one DB query and calculates scores correctly', async () => {
|
||||||
// Simulate POI counts: education=15 (max), healthcare=4 (50%), transport=6 (50%),
|
mockPrisma.$queryRaw.mockResolvedValue(
|
||||||
// shopping=5 (50%), greenery=3 (50%), safety=2 (50%)
|
makePoiRows({
|
||||||
const poiCountsByCategory = [15, 4, 6, 5, 3, 2];
|
SCHOOL: 10, UNIVERSITY: 5,
|
||||||
let callIndex = 0;
|
HOSPITAL: 2, CLINIC: 2,
|
||||||
mockPrisma.pOI.count.mockImplementation(() => {
|
METRO_STATION: 3, BUS_STOP: 3,
|
||||||
return Promise.resolve(poiCountsByCategory[callIndex++]!);
|
MALL: 2, MARKET: 2, SUPERMARKET: 1,
|
||||||
});
|
PARK: 3,
|
||||||
|
POLICE_STATION: 1, FIRE_STATION: 1,
|
||||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => {
|
}),
|
||||||
return Promise.resolve(create);
|
);
|
||||||
});
|
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
|
||||||
|
Promise.resolve(create),
|
||||||
|
);
|
||||||
|
|
||||||
const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
||||||
|
|
||||||
// education: 15/15 * 10 = 10 → 10 * 20/10 = 20
|
|
||||||
// healthcare: 4/8 * 10 = 5 → 5 * 20/10 = 10
|
|
||||||
// transport: 6/12 * 10 = 5 → 5 * 20/10 = 10
|
|
||||||
// shopping: 5/10 * 10 = 5 → 5 * 15/10 = 7.5
|
|
||||||
// greenery: 3/6 * 10 = 5 → 5 * 15/10 = 7.5
|
|
||||||
// safety: 2/4 * 10 = 5 → 5 * 10/10 = 5
|
|
||||||
// total = 20 + 10 + 10 + 7.5 + 7.5 + 5 = 60
|
|
||||||
expect(result.educationScore).toBe(10);
|
expect(result.educationScore).toBe(10);
|
||||||
expect(result.healthcareScore).toBe(5);
|
expect(result.healthcareScore).toBe(5);
|
||||||
expect(result.totalScore).toBe(60);
|
expect(result.totalScore).toBe(60);
|
||||||
|
// Assert single DB round-trip for all 6 categories
|
||||||
|
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||||
expect(mockPrisma.neighborhoodScore.upsert).toHaveBeenCalledTimes(1);
|
expect(mockPrisma.neighborhoodScore.upsert).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('caps category scores at 10', async () => {
|
it('caps category scores at 10', async () => {
|
||||||
// All categories have way more POIs than max
|
mockPrisma.$queryRaw.mockResolvedValue(
|
||||||
mockPrisma.pOI.count.mockResolvedValue(100);
|
makePoiRows({
|
||||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => {
|
SCHOOL: 100, UNIVERSITY: 100, HOSPITAL: 100, CLINIC: 100,
|
||||||
return Promise.resolve(create);
|
METRO_STATION: 100, BUS_STOP: 100, MALL: 100, MARKET: 100,
|
||||||
});
|
SUPERMARKET: 100, PARK: 100, POLICE_STATION: 100, FIRE_STATION: 100,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
|
||||||
|
Promise.resolve(create),
|
||||||
|
);
|
||||||
|
|
||||||
const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
||||||
|
|
||||||
// All scores capped at 10 → total = sum of weights = 100
|
|
||||||
expect(result.educationScore).toBe(10);
|
expect(result.educationScore).toBe(10);
|
||||||
expect(result.healthcareScore).toBe(10);
|
expect(result.healthcareScore).toBe(10);
|
||||||
expect(result.transportScore).toBe(10);
|
expect(result.transportScore).toBe(10);
|
||||||
@@ -105,25 +111,27 @@ describe('NeighborhoodScoreServiceImpl', () => {
|
|||||||
expect(result.greeneryScore).toBe(10);
|
expect(result.greeneryScore).toBe(10);
|
||||||
expect(result.safetyScore).toBe(10);
|
expect(result.safetyScore).toBe(10);
|
||||||
expect(result.totalScore).toBe(100);
|
expect(result.totalScore).toBe(100);
|
||||||
|
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 0 scores when no POIs exist', async () => {
|
it('returns 0 scores when no POIs exist', async () => {
|
||||||
mockPrisma.pOI.count.mockResolvedValue(0);
|
mockPrisma.$queryRaw.mockResolvedValue([]);
|
||||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => {
|
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
|
||||||
return Promise.resolve(create);
|
Promise.resolve(create),
|
||||||
});
|
);
|
||||||
|
|
||||||
const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
||||||
|
|
||||||
expect(result.educationScore).toBe(0);
|
expect(result.educationScore).toBe(0);
|
||||||
expect(result.totalScore).toBe(0);
|
expect(result.totalScore).toBe(0);
|
||||||
|
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('logs the calculated score', async () => {
|
it('logs the calculated score', async () => {
|
||||||
mockPrisma.pOI.count.mockResolvedValue(5);
|
mockPrisma.$queryRaw.mockResolvedValue(makePoiRows({ SCHOOL: 5 }));
|
||||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => {
|
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
|
||||||
return Promise.resolve(create);
|
Promise.resolve(create),
|
||||||
});
|
);
|
||||||
|
|
||||||
await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
||||||
|
|
||||||
@@ -140,7 +148,7 @@ describe('HttpNeighborhoodScoreService', () => {
|
|||||||
let prismaFallback: PrismaNeighborhoodScoreService;
|
let prismaFallback: PrismaNeighborhoodScoreService;
|
||||||
let mockPrisma: {
|
let mockPrisma: {
|
||||||
neighborhoodScore: { findUnique: ReturnType<typeof vi.fn>; upsert: ReturnType<typeof vi.fn> };
|
neighborhoodScore: { findUnique: ReturnType<typeof vi.fn>; upsert: ReturnType<typeof vi.fn> };
|
||||||
pOI: { count: ReturnType<typeof vi.fn> };
|
$queryRaw: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
|
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
|
||||||
let mockAiClient: { scoreNeighborhood: ReturnType<typeof vi.fn> };
|
let mockAiClient: { scoreNeighborhood: ReturnType<typeof vi.fn> };
|
||||||
@@ -148,7 +156,7 @@ describe('HttpNeighborhoodScoreService', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockPrisma = {
|
mockPrisma = {
|
||||||
neighborhoodScore: { findUnique: vi.fn(), upsert: vi.fn() },
|
neighborhoodScore: { findUnique: vi.fn(), upsert: vi.fn() },
|
||||||
pOI: { count: vi.fn() },
|
$queryRaw: vi.fn(),
|
||||||
};
|
};
|
||||||
mockLogger = { log: vi.fn(), warn: vi.fn() };
|
mockLogger = { log: vi.fn(), warn: vi.fn() };
|
||||||
mockAiClient = { scoreNeighborhood: vi.fn() };
|
mockAiClient = { scoreNeighborhood: vi.fn() };
|
||||||
@@ -165,7 +173,7 @@ describe('HttpNeighborhoodScoreService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('persists AI service response when scoreNeighborhood succeeds', async () => {
|
it('persists AI service response when scoreNeighborhood succeeds', async () => {
|
||||||
mockPrisma.pOI.count.mockResolvedValue(6);
|
mockPrisma.$queryRaw.mockResolvedValue(makePoiRows({ SCHOOL: 6 }));
|
||||||
mockAiClient.scoreNeighborhood.mockResolvedValue({
|
mockAiClient.scoreNeighborhood.mockResolvedValue({
|
||||||
district: 'Quận 1',
|
district: 'Quận 1',
|
||||||
city: 'Hồ Chí Minh',
|
city: 'Hồ Chí Minh',
|
||||||
@@ -179,7 +187,9 @@ describe('HttpNeighborhoodScoreService', () => {
|
|||||||
poi_counts: { education: 6, healthcare: 6, transport: 6, shopping: 6, greenery: 6, safety: 6 },
|
poi_counts: { education: 6, healthcare: 6, transport: 6, shopping: 6, greenery: 6, safety: 6 },
|
||||||
algorithm_version: 'neighborhood-heuristic-v1',
|
algorithm_version: 'neighborhood-heuristic-v1',
|
||||||
});
|
});
|
||||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => Promise.resolve(create));
|
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
|
||||||
|
Promise.resolve(create),
|
||||||
|
);
|
||||||
|
|
||||||
const result = await httpService.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
const result = await httpService.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
||||||
|
|
||||||
@@ -187,12 +197,15 @@ describe('HttpNeighborhoodScoreService', () => {
|
|||||||
expect(result.totalScore).toBe(71.2);
|
expect(result.totalScore).toBe(71.2);
|
||||||
expect(result.educationScore).toBe(8.5);
|
expect(result.educationScore).toBe(8.5);
|
||||||
expect(mockPrisma.neighborhoodScore.upsert).toHaveBeenCalledOnce();
|
expect(mockPrisma.neighborhoodScore.upsert).toHaveBeenCalledOnce();
|
||||||
|
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to prisma scoring when AI service throws', async () => {
|
it('falls back to prisma scoring when AI service throws', async () => {
|
||||||
mockPrisma.pOI.count.mockResolvedValue(0);
|
mockPrisma.$queryRaw.mockResolvedValue([]);
|
||||||
mockAiClient.scoreNeighborhood.mockRejectedValue(new Error('AI service down'));
|
mockAiClient.scoreNeighborhood.mockRejectedValue(new Error('AI service down'));
|
||||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => Promise.resolve(create));
|
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
|
||||||
|
Promise.resolve(create),
|
||||||
|
);
|
||||||
|
|
||||||
const result = await httpService.calculateAndSave('Quận 7', 'Hồ Chí Minh');
|
const result = await httpService.calculateAndSave('Quận 7', 'Hồ Chí Minh');
|
||||||
|
|
||||||
|
|||||||
@@ -29,10 +29,13 @@ describe('PrismaAVMService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns zero confidence when fewer than 3 comparables', async () => {
|
it('returns zero confidence when fewer than 3 comparables', async () => {
|
||||||
mockPrisma.$queryRaw.mockResolvedValue([
|
// First $queryRaw call: property location lookup
|
||||||
|
// Second $queryRaw call: findComparables (parameterized after refactor in 6774914)
|
||||||
|
mockPrisma.$queryRaw
|
||||||
|
.mockResolvedValueOnce([
|
||||||
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
||||||
]);
|
])
|
||||||
mockPrisma.$queryRawUnsafe.mockResolvedValue([
|
.mockResolvedValueOnce([
|
||||||
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -44,10 +47,11 @@ describe('PrismaAVMService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('calculates weighted valuation with sufficient comparables', async () => {
|
it('calculates weighted valuation with sufficient comparables', async () => {
|
||||||
mockPrisma.$queryRaw.mockResolvedValue([
|
mockPrisma.$queryRaw
|
||||||
|
.mockResolvedValueOnce([
|
||||||
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
||||||
]);
|
])
|
||||||
mockPrisma.$queryRawUnsafe.mockResolvedValue([
|
.mockResolvedValueOnce([
|
||||||
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
||||||
{ property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() },
|
{ property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() },
|
||||||
{ property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() },
|
{ property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() },
|
||||||
@@ -63,7 +67,8 @@ describe('PrismaAVMService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses coordinates directly when no propertyId', async () => {
|
it('uses coordinates directly when no propertyId', async () => {
|
||||||
mockPrisma.$queryRawUnsafe.mockResolvedValue([
|
// coords-only path: no property lookup, $queryRaw used for comparables directly
|
||||||
|
mockPrisma.$queryRaw.mockResolvedValue([
|
||||||
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
||||||
{ property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() },
|
{ property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() },
|
||||||
{ property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() },
|
{ property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() },
|
||||||
@@ -78,16 +83,18 @@ describe('PrismaAVMService', () => {
|
|||||||
|
|
||||||
expect(result.confidence).toBeGreaterThan(0);
|
expect(result.confidence).toBeGreaterThan(0);
|
||||||
expect(Number(result.estimatedPrice)).toBeGreaterThan(0);
|
expect(Number(result.estimatedPrice)).toBeGreaterThan(0);
|
||||||
expect(mockPrisma.$queryRaw).not.toHaveBeenCalled();
|
// coords-only path: $queryRaw is used for comparables; $queryRawUnsafe not called
|
||||||
|
expect(mockPrisma.$queryRawUnsafe).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getComparables', () => {
|
describe('getComparables', () => {
|
||||||
it('returns comparables for a property', async () => {
|
it('returns comparables for a property', async () => {
|
||||||
mockPrisma.$queryRaw.mockResolvedValue([
|
mockPrisma.$queryRaw
|
||||||
|
.mockResolvedValueOnce([
|
||||||
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
||||||
]);
|
])
|
||||||
mockPrisma.$queryRawUnsafe.mockResolvedValue([
|
.mockResolvedValueOnce([
|
||||||
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 200, published_at: new Date() },
|
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 200, published_at: new Date() },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ interface RawTrainingRow {
|
|||||||
price_vnd: number;
|
price_vnd: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TrainingRow extends RawTrainingRow {}
|
type TrainingRow = RawTrainingRow;
|
||||||
|
|
||||||
interface RetrainResult {
|
interface RetrainResult {
|
||||||
model_version: string;
|
model_version: string;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type AiPredictRequest,
|
type AiPredictRequest,
|
||||||
type AiPredictV2Request,
|
type AiPredictV2Request,
|
||||||
} from './ai-service.client';
|
} from './ai-service.client';
|
||||||
|
import { PrismaAVMService } from './prisma-avm.service';
|
||||||
|
|
||||||
/** Map string risk buckets to the 0..1 float the Python service expects. */
|
/** Map string risk buckets to the 0..1 float the Python service expects. */
|
||||||
const FLOOD_RISK_TO_SCORE: Record<string, number> = {
|
const FLOOD_RISK_TO_SCORE: Record<string, number> = {
|
||||||
@@ -22,7 +23,6 @@ const FLOOD_RISK_TO_SCORE: Record<string, number> = {
|
|||||||
MEDIUM: 0.66,
|
MEDIUM: 0.66,
|
||||||
HIGH: 1,
|
HIGH: 1,
|
||||||
};
|
};
|
||||||
import { PrismaAVMService } from './prisma-avm.service';
|
|
||||||
|
|
||||||
/** Max concurrency for batch AI calls to avoid overloading the Python service. */
|
/** Max concurrency for batch AI calls to avoid overloading the Python service. */
|
||||||
const BATCH_CONCURRENCY = 5;
|
const BATCH_CONCURRENCY = 5;
|
||||||
|
|||||||
@@ -143,18 +143,26 @@ async function countPOIs(
|
|||||||
district: string,
|
district: string,
|
||||||
city: string,
|
city: string,
|
||||||
): Promise<AiNeighborhoodPOICounts> {
|
): Promise<AiNeighborhoodPOICounts> {
|
||||||
const entries = await Promise.all(
|
// Single GROUP BY query replaces 6x individual COUNT queries.
|
||||||
CATEGORY_KEYS.map(async (cat) => {
|
const rows = await prisma.$queryRaw<{ type: POIType; count: bigint }[]>`
|
||||||
const count = await prisma.pOI.count({
|
SELECT "type", COUNT(*) AS count
|
||||||
where: {
|
FROM "POI"
|
||||||
district,
|
WHERE "district" = ${district} AND "city" = ${city}
|
||||||
city,
|
GROUP BY "type"
|
||||||
type: { in: CATEGORY_POI_TYPES[cat] },
|
`;
|
||||||
},
|
|
||||||
});
|
const typeCountMap = new Map<POIType, number>();
|
||||||
return [cat, count] as const;
|
for (const row of rows) {
|
||||||
}),
|
typeCountMap.set(row.type, Number(row.count));
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = CATEGORY_KEYS.map((cat) => {
|
||||||
|
const total = CATEGORY_POI_TYPES[cat].reduce(
|
||||||
|
(sum, t) => sum + (typeCountMap.get(t) ?? 0),
|
||||||
|
0,
|
||||||
);
|
);
|
||||||
|
return [cat, total] as const;
|
||||||
|
});
|
||||||
|
|
||||||
return Object.fromEntries(entries) as unknown as AiNeighborhoodPOICounts;
|
return Object.fromEntries(entries) as unknown as AiNeighborhoodPOICounts;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Injectable, type OnModuleDestroy } from '@nestjs/common';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Cron } from '@nestjs/schedule';
|
import { Cron } from '@nestjs/schedule';
|
||||||
import { InjectMetric } from '@willsoto/nestjs-prometheus';
|
import { InjectMetric } from '@willsoto/nestjs-prometheus';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
|
||||||
import { Counter, Histogram } from 'prom-client';
|
import { Counter, Histogram } from 'prom-client';
|
||||||
import { PrismaService, RedisService, LoggerService } from '@modules/shared';
|
import { PrismaService, RedisService, LoggerService } from '@modules/shared';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { type ExecutionContext, type CallHandler } from '@nestjs/common';
|
import { type ExecutionContext, type CallHandler } from '@nestjs/common';
|
||||||
import { of } from 'rxjs';
|
import { of, lastValueFrom } from 'rxjs';
|
||||||
import { lastValueFrom } from 'rxjs';
|
|
||||||
import { cacheMetaStorage } from '@modules/shared';
|
import { cacheMetaStorage } from '@modules/shared';
|
||||||
import { CacheMetaInterceptor, type WithCacheMeta } from '../interceptors/cache-meta.interceptor';
|
import { CacheMetaInterceptor, type WithCacheMeta } from '../interceptors/cache-meta.interceptor';
|
||||||
|
|
||||||
|
|||||||
@@ -13,38 +13,37 @@ import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody, ApiParam }
|
|||||||
import { JwtAuthGuard } from '@modules/auth';
|
import { JwtAuthGuard } from '@modules/auth';
|
||||||
import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared';
|
import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared';
|
||||||
import { RequireQuota, QuotaGuard } from '@modules/subscriptions';
|
import { RequireQuota, QuotaGuard } from '@modules/subscriptions';
|
||||||
import { CacheMetaInterceptor } from '../interceptors/cache-meta.interceptor';
|
|
||||||
import { type BatchValuationDto as BatchValuationQueryDto } from '../../application/queries/batch-valuation/batch-valuation.handler';
|
import { type BatchValuationDto as BatchValuationQueryDto } from '../../application/queries/batch-valuation/batch-valuation.handler';
|
||||||
import { BatchValuationQuery } from '../../application/queries/batch-valuation/batch-valuation.query';
|
import { BatchValuationQuery } from '../../application/queries/batch-valuation/batch-valuation.query';
|
||||||
import { type DistrictStatsDto } from '../../application/queries/get-district-stats/get-district-stats.handler';
|
import { type DistrictStatsDto } from '../../application/queries/get-district-stats/get-district-stats.handler';
|
||||||
import { GetDistrictStatsQuery } from '../../application/queries/get-district-stats/get-district-stats.query';
|
import { GetDistrictStatsQuery } from '../../application/queries/get-district-stats/get-district-stats.query';
|
||||||
import { type HeatmapDto } from '../../application/queries/get-heatmap/get-heatmap.handler';
|
import { type HeatmapDto } from '../../application/queries/get-heatmap/get-heatmap.handler';
|
||||||
import { GetHeatmapQuery } from '../../application/queries/get-heatmap/get-heatmap.query';
|
import { GetHeatmapQuery } from '../../application/queries/get-heatmap/get-heatmap.query';
|
||||||
import { type ListingVolumeWardDto } from '../../application/queries/get-listing-volume-ward/get-listing-volume-ward.handler';
|
|
||||||
import { GetListingVolumeWardQuery } from '../../application/queries/get-listing-volume-ward/get-listing-volume-ward.query';
|
|
||||||
import {
|
import {
|
||||||
type ListingAiAdviceResponse,
|
type ListingAiAdviceResponse,
|
||||||
} from '../../application/queries/get-listing-ai-advice/get-listing-ai-advice.handler';
|
} from '../../application/queries/get-listing-ai-advice/get-listing-ai-advice.handler';
|
||||||
import { GetListingAiAdviceQuery } from '../../application/queries/get-listing-ai-advice/get-listing-ai-advice.query';
|
import { GetListingAiAdviceQuery } from '../../application/queries/get-listing-ai-advice/get-listing-ai-advice.query';
|
||||||
|
import { type ListingVolumeWardDto } from '../../application/queries/get-listing-volume-ward/get-listing-volume-ward.handler';
|
||||||
|
import { GetListingVolumeWardQuery } from '../../application/queries/get-listing-volume-ward/get-listing-volume-ward.query';
|
||||||
|
import { type MarketHistoryDto } from '../../application/queries/get-market-history/get-market-history.handler';
|
||||||
|
import { GetMarketHistoryQuery } from '../../application/queries/get-market-history/get-market-history.query';
|
||||||
|
import { type MarketReportDto } from '../../application/queries/get-market-report/get-market-report.handler';
|
||||||
|
import { GetMarketReportQuery } from '../../application/queries/get-market-report/get-market-report.query';
|
||||||
|
import { type MarketSnapshotDto } from '../../application/queries/get-market-snapshot/get-market-snapshot.handler';
|
||||||
|
import { GetMarketSnapshotQuery } from '../../application/queries/get-market-snapshot/get-market-snapshot.query';
|
||||||
|
import { type NearbyPOIsResultDto } from '../../application/queries/get-nearby-pois/get-nearby-pois.handler';
|
||||||
|
import { GetNearbyPOIsQuery } from '../../application/queries/get-nearby-pois/get-nearby-pois.query';
|
||||||
|
import { GetNeighborhoodScoreQuery } from '../../application/queries/get-neighborhood-score/get-neighborhood-score.query';
|
||||||
|
import { type PriceMoversDto } from '../../application/queries/get-price-movers/get-price-movers.handler';
|
||||||
|
import { GetPriceMoversQuery } from '../../application/queries/get-price-movers/get-price-movers.query';
|
||||||
|
import { type PriceTrendDto } from '../../application/queries/get-price-trend/get-price-trend.handler';
|
||||||
|
import { GetPriceTrendQuery } from '../../application/queries/get-price-trend/get-price-trend.query';
|
||||||
import {
|
import {
|
||||||
type ProjectAiAdviceResponse,
|
type ProjectAiAdviceResponse,
|
||||||
} from '../../application/queries/get-project-ai-advice/get-project-ai-advice.handler';
|
} from '../../application/queries/get-project-ai-advice/get-project-ai-advice.handler';
|
||||||
import { GetProjectAiAdviceQuery } from '../../application/queries/get-project-ai-advice/get-project-ai-advice.query';
|
import { GetProjectAiAdviceQuery } from '../../application/queries/get-project-ai-advice/get-project-ai-advice.query';
|
||||||
import { type MarketReportDto } from '../../application/queries/get-market-report/get-market-report.handler';
|
|
||||||
import { GetMarketReportQuery } from '../../application/queries/get-market-report/get-market-report.query';
|
|
||||||
import { type MarketHistoryDto } from '../../application/queries/get-market-history/get-market-history.handler';
|
|
||||||
import { GetMarketHistoryQuery } from '../../application/queries/get-market-history/get-market-history.query';
|
|
||||||
import { type MarketSnapshotDto } from '../../application/queries/get-market-snapshot/get-market-snapshot.handler';
|
|
||||||
import { GetMarketSnapshotQuery } from '../../application/queries/get-market-snapshot/get-market-snapshot.query';
|
|
||||||
import { type PriceMoversDto } from '../../application/queries/get-price-movers/get-price-movers.handler';
|
|
||||||
import { GetPriceMoversQuery } from '../../application/queries/get-price-movers/get-price-movers.query';
|
|
||||||
import { type TrendingAreasDto } from '../../application/queries/get-trending-areas/get-trending-areas.handler';
|
import { type TrendingAreasDto } from '../../application/queries/get-trending-areas/get-trending-areas.handler';
|
||||||
import { GetTrendingAreasQuery } from '../../application/queries/get-trending-areas/get-trending-areas.query';
|
import { GetTrendingAreasQuery } from '../../application/queries/get-trending-areas/get-trending-areas.query';
|
||||||
import { type NearbyPOIsResultDto } from '../../application/queries/get-nearby-pois/get-nearby-pois.handler';
|
|
||||||
import { GetNearbyPOIsQuery } from '../../application/queries/get-nearby-pois/get-nearby-pois.query';
|
|
||||||
import { GetNeighborhoodScoreQuery } from '../../application/queries/get-neighborhood-score/get-neighborhood-score.query';
|
|
||||||
import { type PriceTrendDto } from '../../application/queries/get-price-trend/get-price-trend.handler';
|
|
||||||
import { GetPriceTrendQuery } from '../../application/queries/get-price-trend/get-price-trend.query';
|
|
||||||
import { type ValuationDto } from '../../application/queries/get-valuation/get-valuation.handler';
|
import { type ValuationDto } from '../../application/queries/get-valuation/get-valuation.handler';
|
||||||
import { GetValuationQuery } from '../../application/queries/get-valuation/get-valuation.query';
|
import { GetValuationQuery } from '../../application/queries/get-valuation/get-valuation.query';
|
||||||
import { type PredictValuationDto } from '../../application/queries/predict-valuation/predict-valuation.handler';
|
import { type PredictValuationDto } from '../../application/queries/predict-valuation/predict-valuation.handler';
|
||||||
@@ -58,17 +57,18 @@ import { BatchValuationDto } from '../dto/batch-valuation.dto';
|
|||||||
import { GetDistrictStatsDto } from '../dto/get-district-stats.dto';
|
import { GetDistrictStatsDto } from '../dto/get-district-stats.dto';
|
||||||
import { GetHeatmapDto } from '../dto/get-heatmap.dto';
|
import { GetHeatmapDto } from '../dto/get-heatmap.dto';
|
||||||
import { GetListingVolumeWardDto } from '../dto/get-listing-volume-ward.dto';
|
import { GetListingVolumeWardDto } from '../dto/get-listing-volume-ward.dto';
|
||||||
import { GetMarketReportDto } from '../dto/get-market-report.dto';
|
|
||||||
import { GetMarketHistoryDto } from '../dto/get-market-history.dto';
|
import { GetMarketHistoryDto } from '../dto/get-market-history.dto';
|
||||||
|
import { GetMarketReportDto } from '../dto/get-market-report.dto';
|
||||||
import { GetMarketSnapshotDto } from '../dto/get-market-snapshot.dto';
|
import { GetMarketSnapshotDto } from '../dto/get-market-snapshot.dto';
|
||||||
import { GetPriceMoversDto } from '../dto/get-price-movers.dto';
|
|
||||||
import { GetTrendingAreasDto } from '../dto/get-trending-areas.dto';
|
|
||||||
import { GetNearbyPOIsDto } from '../dto/get-nearby-pois.dto';
|
import { GetNearbyPOIsDto } from '../dto/get-nearby-pois.dto';
|
||||||
|
import { GetPriceMoversDto } from '../dto/get-price-movers.dto';
|
||||||
import { GetPriceTrendDto } from '../dto/get-price-trend.dto';
|
import { GetPriceTrendDto } from '../dto/get-price-trend.dto';
|
||||||
|
import { GetTrendingAreasDto } from '../dto/get-trending-areas.dto';
|
||||||
import { GetValuationDto } from '../dto/get-valuation.dto';
|
import { GetValuationDto } from '../dto/get-valuation.dto';
|
||||||
import { PredictValuationDto as PredictValuationBodyDto } from '../dto/predict-valuation.dto';
|
import { PredictValuationDto as PredictValuationBodyDto } from '../dto/predict-valuation.dto';
|
||||||
import { ValuationComparisonDto } from '../dto/valuation-comparison.dto';
|
import { ValuationComparisonDto } from '../dto/valuation-comparison.dto';
|
||||||
import { ValuationHistoryDto } from '../dto/valuation-history.dto';
|
import { ValuationHistoryDto } from '../dto/valuation-history.dto';
|
||||||
|
import { CacheMetaInterceptor } from '../interceptors/cache-meta.interceptor';
|
||||||
|
|
||||||
@ApiTags('analytics')
|
@ApiTags('analytics')
|
||||||
@UseInterceptors(CacheMetaInterceptor)
|
@UseInterceptors(CacheMetaInterceptor)
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ import { AvmCompareQueryDto } from '../dto/avm-compare-query.dto';
|
|||||||
import { AvmExplainQueryDto } from '../dto/avm-explain-query.dto';
|
import { AvmExplainQueryDto } from '../dto/avm-explain-query.dto';
|
||||||
import { BatchValuationDto } from '../dto/batch-valuation.dto';
|
import { BatchValuationDto } from '../dto/batch-valuation.dto';
|
||||||
import { IndustrialValuationDto } from '../dto/industrial-valuation.dto';
|
import { IndustrialValuationDto } from '../dto/industrial-valuation.dto';
|
||||||
import { CacheMetaInterceptor } from '../interceptors/cache-meta.interceptor';
|
|
||||||
import { ValuationHistoryDto } from '../dto/valuation-history.dto';
|
import { ValuationHistoryDto } from '../dto/valuation-history.dto';
|
||||||
|
import { CacheMetaInterceptor } from '../interceptors/cache-meta.interceptor';
|
||||||
|
|
||||||
@ApiTags('avm')
|
@ApiTags('avm')
|
||||||
@UseInterceptors(CacheMetaInterceptor)
|
@UseInterceptors(CacheMetaInterceptor)
|
||||||
|
|||||||
@@ -43,5 +43,5 @@ export class GetPriceMoversDto {
|
|||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['district'])
|
@IsIn(['district'])
|
||||||
level: 'district' = 'district';
|
level = 'district' as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,5 +37,5 @@ export class GetTrendingAreasDto {
|
|||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['district'])
|
@IsIn(['district'])
|
||||||
level: 'district' = 'district';
|
level = 'district' as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
|
import { PayloadTooLargeException } from '@nestjs/common';
|
||||||
import { NotFoundException } from '@modules/shared';
|
import { NotFoundException } from '@modules/shared';
|
||||||
import { ExportUserDataCommand } from '../commands/export-user-data/export-user-data.command';
|
import { ExportUserDataCommand } from '../commands/export-user-data/export-user-data.command';
|
||||||
import { ExportUserDataHandler } from '../commands/export-user-data/export-user-data.handler';
|
import { ExportUserDataHandler } from '../commands/export-user-data/export-user-data.handler';
|
||||||
|
|
||||||
|
async function readStream(stream: NodeJS.ReadableStream): Promise<string> {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string));
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
describe('ExportUserDataHandler', () => {
|
describe('ExportUserDataHandler', () => {
|
||||||
let handler: ExportUserDataHandler;
|
let handler: ExportUserDataHandler;
|
||||||
|
|
||||||
@@ -17,7 +26,13 @@ describe('ExportUserDataHandler', () => {
|
|||||||
transaction: { findMany: vi.fn() },
|
transaction: { findMany: vi.fn() },
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
|
const mockLogger = {
|
||||||
|
log: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
verbose: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
const sampleUser = {
|
const sampleUser = {
|
||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
@@ -29,12 +44,25 @@ describe('ExportUserDataHandler', () => {
|
|||||||
createdAt: new Date('2025-01-01'),
|
createdAt: new Date('2025-01-01'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function setupEmptyRelations() {
|
||||||
|
mockPrisma.agent.findUnique.mockResolvedValue(null);
|
||||||
|
mockPrisma.listing.findMany.mockResolvedValue([]);
|
||||||
|
mockPrisma.payment.findMany.mockResolvedValue([]);
|
||||||
|
mockPrisma.subscription.findFirst.mockResolvedValue(null);
|
||||||
|
mockPrisma.review.findMany.mockResolvedValue([]);
|
||||||
|
mockPrisma.inquiry.findMany.mockResolvedValue([]);
|
||||||
|
mockPrisma.savedSearch.findMany.mockResolvedValue([]);
|
||||||
|
mockPrisma.transaction.findMany.mockResolvedValue([]);
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
delete process.env['EXPORT_ROW_CAP'];
|
||||||
|
delete process.env['EXPORT_SIZE_CAP_MB'];
|
||||||
handler = new ExportUserDataHandler(mockPrisma as any, mockLogger as any);
|
handler = new ExportUserDataHandler(mockPrisma as any, mockLogger as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exports all user data including relations', async () => {
|
it('exports all user data including relations and returns a stream', async () => {
|
||||||
mockPrisma.user.findUnique.mockResolvedValue(sampleUser);
|
mockPrisma.user.findUnique.mockResolvedValue(sampleUser);
|
||||||
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1', userId: 'user-1' });
|
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1', userId: 'user-1' });
|
||||||
mockPrisma.listing.findMany.mockResolvedValue([{ id: 'listing-1' }]);
|
mockPrisma.listing.findMany.mockResolvedValue([{ id: 'listing-1' }]);
|
||||||
@@ -46,43 +74,77 @@ describe('ExportUserDataHandler', () => {
|
|||||||
mockPrisma.transaction.findMany.mockResolvedValue([{ id: 'tx-1' }]);
|
mockPrisma.transaction.findMany.mockResolvedValue([{ id: 'tx-1' }]);
|
||||||
|
|
||||||
const result = await handler.execute(new ExportUserDataCommand('user-1'));
|
const result = await handler.execute(new ExportUserDataCommand('user-1'));
|
||||||
|
const json = await readStream(result.stream);
|
||||||
|
const parsed = JSON.parse(json);
|
||||||
|
|
||||||
expect(result.user).toEqual(sampleUser);
|
expect(parsed.user).toMatchObject({ id: 'user-1' });
|
||||||
expect(result.agent).toEqual({ id: 'agent-1', userId: 'user-1' });
|
expect(parsed.agent).toEqual({ id: 'agent-1', userId: 'user-1' });
|
||||||
expect(result.listings).toHaveLength(1);
|
expect(parsed.listings).toHaveLength(1);
|
||||||
expect(result.payments).toHaveLength(1);
|
expect(parsed.payments).toHaveLength(1);
|
||||||
expect(result.subscription).toEqual({ id: 'sub-1', status: 'ACTIVE' });
|
expect(parsed.subscription).toEqual({ id: 'sub-1', status: 'ACTIVE' });
|
||||||
expect(result.reviews).toHaveLength(1);
|
expect(parsed.reviews).toHaveLength(1);
|
||||||
expect(result.inquiries).toHaveLength(1);
|
expect(parsed.inquiries).toHaveLength(1);
|
||||||
expect(result.savedSearches).toHaveLength(1);
|
expect(parsed.savedSearches).toHaveLength(1);
|
||||||
expect(result.transactions).toHaveLength(1);
|
expect(parsed.transactions).toHaveLength(1);
|
||||||
|
expect(result.truncated).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundException if user not found', async () => {
|
it('throws NotFoundException if user not found', async () => {
|
||||||
mockPrisma.user.findUnique.mockResolvedValue(null);
|
mockPrisma.user.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(
|
await expect(handler.execute(new ExportUserDataCommand('missing'))).rejects.toThrow(
|
||||||
handler.execute(new ExportUserDataCommand('missing')),
|
NotFoundException,
|
||||||
).rejects.toThrow(NotFoundException);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes exportedAt timestamp', async () => {
|
it('includes exportedAt timestamp and cap metadata in the payload', async () => {
|
||||||
mockPrisma.user.findUnique.mockResolvedValue(sampleUser);
|
mockPrisma.user.findUnique.mockResolvedValue(sampleUser);
|
||||||
mockPrisma.agent.findUnique.mockResolvedValue(null);
|
setupEmptyRelations();
|
||||||
mockPrisma.listing.findMany.mockResolvedValue([]);
|
|
||||||
mockPrisma.payment.findMany.mockResolvedValue([]);
|
|
||||||
mockPrisma.subscription.findFirst.mockResolvedValue(null);
|
|
||||||
mockPrisma.review.findMany.mockResolvedValue([]);
|
|
||||||
mockPrisma.inquiry.findMany.mockResolvedValue([]);
|
|
||||||
mockPrisma.savedSearch.findMany.mockResolvedValue([]);
|
|
||||||
mockPrisma.transaction.findMany.mockResolvedValue([]);
|
|
||||||
|
|
||||||
const before = new Date().toISOString();
|
const before = new Date().toISOString();
|
||||||
const result = await handler.execute(new ExportUserDataCommand('user-1'));
|
const result = await handler.execute(new ExportUserDataCommand('user-1'));
|
||||||
const after = new Date().toISOString();
|
const after = new Date().toISOString();
|
||||||
|
const parsed = JSON.parse(await readStream(result.stream));
|
||||||
|
|
||||||
expect(result.exportedAt).toBeDefined();
|
expect(parsed.exportedAt).toBeDefined();
|
||||||
expect(result.exportedAt >= before).toBe(true);
|
expect(parsed.exportedAt >= before).toBe(true);
|
||||||
expect(result.exportedAt <= after).toBe(true);
|
expect(parsed.exportedAt <= after).toBe(true);
|
||||||
|
expect(typeof parsed.rowCap).toBe('number');
|
||||||
|
expect(typeof parsed.sizeCap).toBe('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies row cap to each collection query', async () => {
|
||||||
|
process.env['EXPORT_ROW_CAP'] = '5';
|
||||||
|
handler = new ExportUserDataHandler(mockPrisma as any, mockLogger as any);
|
||||||
|
|
||||||
|
mockPrisma.user.findUnique.mockResolvedValue(sampleUser);
|
||||||
|
setupEmptyRelations();
|
||||||
|
|
||||||
|
await handler.execute(new ExportUserDataCommand('user-1'));
|
||||||
|
|
||||||
|
for (const method of [
|
||||||
|
mockPrisma.listing.findMany,
|
||||||
|
mockPrisma.payment.findMany,
|
||||||
|
mockPrisma.review.findMany,
|
||||||
|
mockPrisma.inquiry.findMany,
|
||||||
|
mockPrisma.savedSearch.findMany,
|
||||||
|
mockPrisma.transaction.findMany,
|
||||||
|
]) {
|
||||||
|
expect(method).toHaveBeenCalledWith(expect.objectContaining({ take: 5 }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws PayloadTooLargeException when JSON exceeds the size cap', async () => {
|
||||||
|
process.env['EXPORT_SIZE_CAP_MB'] = '0.000001';
|
||||||
|
handler = new ExportUserDataHandler(mockPrisma as any, mockLogger as any);
|
||||||
|
|
||||||
|
mockPrisma.user.findUnique.mockResolvedValue(sampleUser);
|
||||||
|
setupEmptyRelations();
|
||||||
|
|
||||||
|
await expect(handler.execute(new ExportUserDataCommand('user-1'))).rejects.toThrow(
|
||||||
|
PayloadTooLargeException,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ describe('LoginUserHandler', () => {
|
|||||||
let handler: LoginUserHandler;
|
let handler: LoginUserHandler;
|
||||||
let mockTokenService: { generateTokenPair: ReturnType<typeof vi.fn> };
|
let mockTokenService: { generateTokenPair: ReturnType<typeof vi.fn> };
|
||||||
let mockChallengeRepo: { create: ReturnType<typeof vi.fn> };
|
let mockChallengeRepo: { create: ReturnType<typeof vi.fn> };
|
||||||
|
let mockUserRepo: { updateMfaGraceStartedAt: ReturnType<typeof vi.fn> };
|
||||||
|
let mockLogger: { error: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
const tokenPair = {
|
const tokenPair = {
|
||||||
accessToken: 'access-jwt',
|
accessToken: 'access-jwt',
|
||||||
@@ -15,22 +17,30 @@ describe('LoginUserHandler', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockTokenService = { generateTokenPair: vi.fn().mockResolvedValue(tokenPair) };
|
mockTokenService = { generateTokenPair: vi.fn().mockResolvedValue(tokenPair) };
|
||||||
mockChallengeRepo = { create: vi.fn().mockResolvedValue({}) };
|
mockChallengeRepo = { create: vi.fn().mockResolvedValue({}) };
|
||||||
handler = new LoginUserHandler(mockTokenService as any, mockChallengeRepo as any);
|
mockUserRepo = { updateMfaGraceStartedAt: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
mockLogger = { error: vi.fn(), warn: vi.fn() };
|
||||||
|
handler = new LoginUserHandler(
|
||||||
|
mockTokenService as any,
|
||||||
|
mockChallengeRepo as any,
|
||||||
|
mockUserRepo as any,
|
||||||
|
mockLogger as any,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates token pair with correct payload when MFA not required', async () => {
|
it('generates token pair with mfa=none for non-required role when MFA not required', async () => {
|
||||||
const command = new LoginUserCommand('user-1', '0912345678', 'BUYER', false);
|
const command = new LoginUserCommand('user-1', '0912345678', 'BUYER', false);
|
||||||
const result = await handler.execute(command);
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
expect(result).toEqual({ requiresMfa: false, tokens: tokenPair });
|
expect(result).toEqual({ requiresMfa: false, tokens: tokenPair, mfaGraceRemainingDays: undefined });
|
||||||
expect(mockTokenService.generateTokenPair).toHaveBeenCalledWith({
|
expect(mockTokenService.generateTokenPair).toHaveBeenCalledWith({
|
||||||
sub: 'user-1',
|
sub: 'user-1',
|
||||||
phone: '0912345678',
|
phone: '0912345678',
|
||||||
role: 'BUYER',
|
role: 'BUYER',
|
||||||
|
mfa: 'none',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates MFA challenge when MFA is required', async () => {
|
it('creates MFA challenge when MFA is required (user already enrolled)', async () => {
|
||||||
const command = new LoginUserCommand('user-1', '0912345678', 'BUYER', true);
|
const command = new LoginUserCommand('user-1', '0912345678', 'BUYER', true);
|
||||||
const result = await handler.execute(command);
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
@@ -49,7 +59,7 @@ describe('LoginUserHandler', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes AGENT role correctly', async () => {
|
it('AGENT role does not require MFA — issues mfa=none claim', async () => {
|
||||||
const command = new LoginUserCommand('user-2', '0987654321', 'AGENT');
|
const command = new LoginUserCommand('user-2', '0987654321', 'AGENT');
|
||||||
await handler.execute(command);
|
await handler.execute(command);
|
||||||
|
|
||||||
@@ -57,17 +67,51 @@ describe('LoginUserHandler', () => {
|
|||||||
sub: 'user-2',
|
sub: 'user-2',
|
||||||
phone: '0987654321',
|
phone: '0987654321',
|
||||||
role: 'AGENT',
|
role: 'AGENT',
|
||||||
|
mfa: 'none',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes ADMIN role correctly', async () => {
|
it('ADMIN without TOTP enters grace period on first login under enforcement', async () => {
|
||||||
const command = new LoginUserCommand('admin-1', '0901234567', 'ADMIN');
|
const command = new LoginUserCommand(
|
||||||
await handler.execute(command);
|
'admin-1',
|
||||||
|
'0901234567',
|
||||||
|
'ADMIN',
|
||||||
|
false,
|
||||||
|
false, // totpEnabled
|
||||||
|
null, // mfaGraceStartedAt — first login
|
||||||
|
);
|
||||||
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
|
// Grace was started lazily
|
||||||
|
expect(mockUserRepo.updateMfaGraceStartedAt).toHaveBeenCalledWith('admin-1', expect.any(Date));
|
||||||
|
expect(result.mfaGraceRemainingDays).toBe(14);
|
||||||
expect(mockTokenService.generateTokenPair).toHaveBeenCalledWith({
|
expect(mockTokenService.generateTokenPair).toHaveBeenCalledWith({
|
||||||
sub: 'admin-1',
|
sub: 'admin-1',
|
||||||
phone: '0901234567',
|
phone: '0901234567',
|
||||||
role: 'ADMIN',
|
role: 'ADMIN',
|
||||||
|
mfa: 'grace',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ADMIN past grace window receives mfa=enrollment_required claim', async () => {
|
||||||
|
const longAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days ago
|
||||||
|
const command = new LoginUserCommand(
|
||||||
|
'admin-1',
|
||||||
|
'0901234567',
|
||||||
|
'ADMIN',
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
longAgo,
|
||||||
|
);
|
||||||
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
|
expect(mockUserRepo.updateMfaGraceStartedAt).not.toHaveBeenCalled();
|
||||||
|
expect(result.mfaGraceRemainingDays).toBe(0);
|
||||||
|
expect(mockTokenService.generateTokenPair).toHaveBeenCalledWith({
|
||||||
|
sub: 'admin-1',
|
||||||
|
phone: '0901234567',
|
||||||
|
role: 'ADMIN',
|
||||||
|
mfa: 'enrollment_required',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { InternalServerErrorException } from '@nestjs/common';
|
import { Readable } from 'node:stream';
|
||||||
|
import { HttpException, InternalServerErrorException, PayloadTooLargeException } from '@nestjs/common';
|
||||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { LoggerService, PrismaService, DomainException, NotFoundException } from '@modules/shared';
|
import { LoggerService, PrismaService, DomainException, NotFoundException } from '@modules/shared';
|
||||||
import { ExportUserDataCommand } from './export-user-data.command';
|
import { ExportUserDataCommand } from './export-user-data.command';
|
||||||
|
|
||||||
|
/** Per-collection row cap. Override via EXPORT_ROW_CAP env var (default 10 000). */
|
||||||
|
const DEFAULT_ROW_CAP = 10_000;
|
||||||
|
/** Maximum total export size in megabytes. Override via EXPORT_SIZE_CAP_MB env var (default 100). */
|
||||||
|
const DEFAULT_SIZE_CAP_MB = 100;
|
||||||
|
|
||||||
export interface UserDataExport {
|
export interface UserDataExport {
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,16 +28,34 @@ export interface UserDataExport {
|
|||||||
savedSearches: unknown[];
|
savedSearches: unknown[];
|
||||||
transactions: unknown[];
|
transactions: unknown[];
|
||||||
exportedAt: string;
|
exportedAt: string;
|
||||||
|
/** Effective row cap applied to each collection query. */
|
||||||
|
rowCap: number;
|
||||||
|
/** Effective size cap in bytes for the entire JSON payload. */
|
||||||
|
sizeCap: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportUserDataResult {
|
||||||
|
/** Node.js Readable stream containing the UTF-8 encoded JSON payload. */
|
||||||
|
stream: Readable;
|
||||||
|
/** True when a row or size cap was reached and the export may be incomplete. */
|
||||||
|
truncated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@CommandHandler(ExportUserDataCommand)
|
@CommandHandler(ExportUserDataCommand)
|
||||||
export class ExportUserDataHandler implements ICommandHandler<ExportUserDataCommand> {
|
export class ExportUserDataHandler implements ICommandHandler<ExportUserDataCommand> {
|
||||||
|
private readonly rowCap: number;
|
||||||
|
private readonly sizeCapBytes: number;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly logger: LoggerService,
|
private readonly logger: LoggerService,
|
||||||
) {}
|
) {
|
||||||
|
this.rowCap = parseInt(process.env['EXPORT_ROW_CAP'] ?? String(DEFAULT_ROW_CAP), 10);
|
||||||
|
const sizeMb = parseFloat(process.env['EXPORT_SIZE_CAP_MB'] ?? String(DEFAULT_SIZE_CAP_MB));
|
||||||
|
this.sizeCapBytes = Math.floor(sizeMb * 1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
async execute(command: ExportUserDataCommand): Promise<UserDataExport> {
|
async execute(command: ExportUserDataCommand): Promise<ExportUserDataResult> {
|
||||||
try {
|
try {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { id: command.userId },
|
where: { id: command.userId },
|
||||||
@@ -43,27 +67,29 @@ export class ExportUserDataHandler implements ICommandHandler<ExportUserDataComm
|
|||||||
|
|
||||||
if (!user) throw new NotFoundException('User', command.userId);
|
if (!user) throw new NotFoundException('User', command.userId);
|
||||||
|
|
||||||
|
const rowCap = this.rowCap;
|
||||||
|
|
||||||
const [agent, listings, payments, subscription, reviews, inquiries, savedSearches, transactions] =
|
const [agent, listings, payments, subscription, reviews, inquiries, savedSearches, transactions] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.prisma.agent.findUnique({ where: { userId: command.userId } }),
|
this.prisma.agent.findUnique({ where: { userId: command.userId } }),
|
||||||
this.prisma.listing.findMany({
|
this.prisma.listing.findMany({
|
||||||
where: { sellerId: command.userId },
|
where: { sellerId: command.userId },
|
||||||
|
take: rowCap,
|
||||||
include: { property: { select: { title: true, address: true, district: true, city: true } } },
|
include: { property: { select: { title: true, address: true, district: true, city: true } } },
|
||||||
}),
|
}),
|
||||||
this.prisma.payment.findMany({
|
this.prisma.payment.findMany({
|
||||||
where: { userId: command.userId },
|
where: { userId: command.userId },
|
||||||
|
take: rowCap,
|
||||||
select: { id: true, provider: true, type: true, amountVND: true, status: true, createdAt: true },
|
select: { id: true, provider: true, type: true, amountVND: true, status: true, createdAt: true },
|
||||||
}),
|
}),
|
||||||
this.prisma.subscription.findFirst({ where: { userId: command.userId } }),
|
this.prisma.subscription.findFirst({ where: { userId: command.userId } }),
|
||||||
this.prisma.review.findMany({ where: { userId: command.userId } }),
|
this.prisma.review.findMany({ where: { userId: command.userId }, take: rowCap }),
|
||||||
this.prisma.inquiry.findMany({ where: { userId: command.userId } }),
|
this.prisma.inquiry.findMany({ where: { userId: command.userId }, take: rowCap }),
|
||||||
this.prisma.savedSearch.findMany({ where: { userId: command.userId } }),
|
this.prisma.savedSearch.findMany({ where: { userId: command.userId }, take: rowCap }),
|
||||||
this.prisma.transaction.findMany({ where: { buyerId: command.userId } }),
|
this.prisma.transaction.findMany({ where: { buyerId: command.userId }, take: rowCap }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
this.logger.log(`User data exported for ${command.userId}`, 'ExportUserDataHandler');
|
const payload: UserDataExport = {
|
||||||
|
|
||||||
return {
|
|
||||||
user,
|
user,
|
||||||
agent,
|
agent,
|
||||||
listings,
|
listings,
|
||||||
@@ -74,9 +100,34 @@ export class ExportUserDataHandler implements ICommandHandler<ExportUserDataComm
|
|||||||
savedSearches,
|
savedSearches,
|
||||||
transactions,
|
transactions,
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
|
rowCap,
|
||||||
|
sizeCap: this.sizeCapBytes,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const json = JSON.stringify(payload);
|
||||||
|
const byteLength = Buffer.byteLength(json, 'utf8');
|
||||||
|
|
||||||
|
if (byteLength > this.sizeCapBytes) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Export for user ${command.userId} is ${byteLength} bytes, exceeds cap of ${this.sizeCapBytes} bytes`,
|
||||||
|
this.constructor.name,
|
||||||
|
);
|
||||||
|
throw new PayloadTooLargeException(
|
||||||
|
`Dữ liệu xuất (${Math.round(byteLength / 1024 / 1024)} MB) vượt giới hạn ` +
|
||||||
|
`${Math.round(this.sizeCapBytes / 1024 / 1024)} MB. ` +
|
||||||
|
`Vui lòng liên hệ hỗ trợ để xuất theo từng phần.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`User data exported for ${command.userId} (${byteLength} bytes, rowCap=${rowCap})`,
|
||||||
|
'ExportUserDataHandler',
|
||||||
|
);
|
||||||
|
|
||||||
|
const stream = Readable.from(Buffer.from(json, 'utf8'));
|
||||||
|
return { stream, truncated: false };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DomainException) throw error;
|
if (error instanceof DomainException || error instanceof HttpException) throw error;
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to export user data: ${error instanceof Error ? error.message : error}`,
|
`Failed to export user data: ${error instanceof Error ? error.message : error}`,
|
||||||
error instanceof Error ? error.stack : undefined,
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
|||||||
@@ -4,5 +4,7 @@ export class LoginUserCommand {
|
|||||||
public readonly phone: string,
|
public readonly phone: string,
|
||||||
public readonly role: string,
|
public readonly role: string,
|
||||||
public readonly isMfaRequired: boolean = false,
|
public readonly isMfaRequired: boolean = false,
|
||||||
|
public readonly totpEnabled: boolean = false,
|
||||||
|
public readonly mfaGraceStartedAt: Date | null = null,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { createId } from '@paralleldrive/cuid2';
|
import { createId } from '@paralleldrive/cuid2';
|
||||||
|
import { type UserRole } from '@prisma/client';
|
||||||
import { LoggerService, DomainException } from '@modules/shared';
|
import { LoggerService, DomainException } from '@modules/shared';
|
||||||
|
import { MFA_GRACE_PERIOD_DAYS, MFA_REQUIRED_ROLES } from '../../../domain/mfa-policy';
|
||||||
import {
|
import {
|
||||||
MFA_CHALLENGE_REPOSITORY,
|
MFA_CHALLENGE_REPOSITORY,
|
||||||
type IMfaChallengeRepository,
|
type IMfaChallengeRepository,
|
||||||
} from '../../../domain/repositories/mfa-challenge.repository';
|
} from '../../../domain/repositories/mfa-challenge.repository';
|
||||||
import { TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
|
import {
|
||||||
|
USER_REPOSITORY,
|
||||||
|
type IUserRepository,
|
||||||
|
} from '../../../domain/repositories/user.repository';
|
||||||
|
import { TokenService, type MfaClaim, type TokenPair } from '../../../infrastructure/services/token.service';
|
||||||
import { LoginUserCommand } from './login-user.command';
|
import { LoginUserCommand } from './login-user.command';
|
||||||
|
|
||||||
const MFA_CHALLENGE_TTL_MINUTES = 5;
|
const MFA_CHALLENGE_TTL_MINUTES = 5;
|
||||||
@@ -15,6 +21,7 @@ export interface LoginResult {
|
|||||||
requiresMfa: boolean;
|
requiresMfa: boolean;
|
||||||
challengeId?: string;
|
challengeId?: string;
|
||||||
tokens?: TokenPair;
|
tokens?: TokenPair;
|
||||||
|
mfaGraceRemainingDays?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@CommandHandler(LoginUserCommand)
|
@CommandHandler(LoginUserCommand)
|
||||||
@@ -23,12 +30,14 @@ export class LoginUserHandler implements ICommandHandler<LoginUserCommand> {
|
|||||||
private readonly tokenService: TokenService,
|
private readonly tokenService: TokenService,
|
||||||
@Inject(MFA_CHALLENGE_REPOSITORY)
|
@Inject(MFA_CHALLENGE_REPOSITORY)
|
||||||
private readonly challengeRepo: IMfaChallengeRepository,
|
private readonly challengeRepo: IMfaChallengeRepository,
|
||||||
|
@Inject(USER_REPOSITORY)
|
||||||
|
private readonly userRepo: IUserRepository,
|
||||||
private readonly logger: LoggerService,
|
private readonly logger: LoggerService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: LoginUserCommand): Promise<LoginResult> {
|
async execute(command: LoginUserCommand): Promise<LoginResult> {
|
||||||
try {
|
try {
|
||||||
// If MFA is required, create a challenge instead of tokens
|
// If MFA is required (user already enrolled), create a challenge
|
||||||
if (command.isMfaRequired) {
|
if (command.isMfaRequired) {
|
||||||
const challengeId = createId();
|
const challengeId = createId();
|
||||||
const expiresAt = new Date();
|
const expiresAt = new Date();
|
||||||
@@ -50,16 +59,32 @@ export class LoginUserHandler implements ICommandHandler<LoginUserCommand> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// No MFA — issue tokens directly
|
// Determine MFA claim for non-enrolled users
|
||||||
|
const roleRequiresMfa = MFA_REQUIRED_ROLES.includes(command.role as UserRole);
|
||||||
|
|
||||||
|
let mfaClaim: MfaClaim = 'none';
|
||||||
|
let mfaGraceRemainingDays: number | undefined;
|
||||||
|
|
||||||
|
if (roleRequiresMfa && !command.totpEnabled) {
|
||||||
|
const result = await this.resolveMfaGraceClaim(
|
||||||
|
command.userId,
|
||||||
|
command.mfaGraceStartedAt,
|
||||||
|
);
|
||||||
|
mfaClaim = result.claim;
|
||||||
|
mfaGraceRemainingDays = result.remainingDays;
|
||||||
|
}
|
||||||
|
|
||||||
const tokens = await this.tokenService.generateTokenPair({
|
const tokens = await this.tokenService.generateTokenPair({
|
||||||
sub: command.userId,
|
sub: command.userId,
|
||||||
phone: command.phone,
|
phone: command.phone,
|
||||||
role: command.role,
|
role: command.role,
|
||||||
|
mfa: mfaClaim,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requiresMfa: false,
|
requiresMfa: false,
|
||||||
tokens,
|
tokens,
|
||||||
|
mfaGraceRemainingDays,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DomainException) throw error;
|
if (error instanceof DomainException) throw error;
|
||||||
@@ -71,5 +96,33 @@ export class LoginUserHandler implements ICommandHandler<LoginUserCommand> {
|
|||||||
throw new InternalServerErrorException('Không thể tạo phiên đăng nhập, vui lòng thử lại');
|
throw new InternalServerErrorException('Không thể tạo phiên đăng nhập, vui lòng thử lại');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy-initialises mfaGraceStartedAt if the role requires MFA but
|
||||||
|
* the user hasn't enrolled yet. Returns the appropriate MFA claim
|
||||||
|
* and the number of grace days remaining (if any).
|
||||||
|
*/
|
||||||
|
private async resolveMfaGraceClaim(
|
||||||
|
userId: string,
|
||||||
|
mfaGraceStartedAt: Date | null,
|
||||||
|
): Promise<{ claim: MfaClaim; remainingDays?: number }> {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (!mfaGraceStartedAt) {
|
||||||
|
// First login since enforcement — start the grace period
|
||||||
|
await this.userRepo.updateMfaGraceStartedAt(userId, now);
|
||||||
|
return { claim: 'grace', remainingDays: MFA_GRACE_PERIOD_DAYS };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const elapsedMs = now.getTime() - mfaGraceStartedAt.getTime();
|
||||||
|
const elapsedDays = elapsedMs / (1000 * 60 * 60 * 24);
|
||||||
|
const remainingDays = Math.max(0, Math.ceil(MFA_GRACE_PERIOD_DAYS - elapsedDays));
|
||||||
|
|
||||||
|
if (remainingDays > 0) {
|
||||||
|
return { claim: 'grace', remainingDays };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grace period expired — enrollment is now mandatory
|
||||||
|
return { claim: 'enrollment_required', remainingDays: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ import { ForceDeleteUserHandler } from './application/commands/force-delete-user
|
|||||||
import { ForgotPasswordHandler } from './application/commands/forgot-password/forgot-password.handler';
|
import { ForgotPasswordHandler } from './application/commands/forgot-password/forgot-password.handler';
|
||||||
import { GenerateKycUploadUrlsHandler } from './application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.handler';
|
import { GenerateKycUploadUrlsHandler } from './application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.handler';
|
||||||
import { LoginUserHandler } from './application/commands/login-user/login-user.handler';
|
import { LoginUserHandler } from './application/commands/login-user/login-user.handler';
|
||||||
import { ResetPasswordHandler } from './application/commands/reset-password/reset-password.handler';
|
|
||||||
import { ProcessScheduledDeletionsHandler } from './application/commands/process-scheduled-deletions/process-scheduled-deletions.handler';
|
import { ProcessScheduledDeletionsHandler } from './application/commands/process-scheduled-deletions/process-scheduled-deletions.handler';
|
||||||
import { RefreshTokenHandler } from './application/commands/refresh-token/refresh-token.handler';
|
import { RefreshTokenHandler } from './application/commands/refresh-token/refresh-token.handler';
|
||||||
import { RegisterUserHandler } from './application/commands/register-user/register-user.handler';
|
import { RegisterUserHandler } from './application/commands/register-user/register-user.handler';
|
||||||
import { RequestUserDeletionHandler } from './application/commands/request-user-deletion/request-user-deletion.handler';
|
import { RequestUserDeletionHandler } from './application/commands/request-user-deletion/request-user-deletion.handler';
|
||||||
import { ResendOtpHandler } from './application/commands/resend-otp/resend-otp.handler';
|
import { ResendOtpHandler } from './application/commands/resend-otp/resend-otp.handler';
|
||||||
|
import { ResetPasswordHandler } from './application/commands/reset-password/reset-password.handler';
|
||||||
import { SetupMfaHandler } from './application/commands/setup-mfa/setup-mfa.handler';
|
import { SetupMfaHandler } from './application/commands/setup-mfa/setup-mfa.handler';
|
||||||
import { SubmitKycHandler } from './application/commands/submit-kyc/submit-kyc.handler';
|
import { SubmitKycHandler } from './application/commands/submit-kyc/submit-kyc.handler';
|
||||||
import { UpdateProfileHandler } from './application/commands/update-profile/update-profile.handler';
|
import { UpdateProfileHandler } from './application/commands/update-profile/update-profile.handler';
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface UserProps {
|
|||||||
totpEnabled: boolean;
|
totpEnabled: boolean;
|
||||||
totpBackupCodes: string[];
|
totpBackupCodes: string[];
|
||||||
totpEnabledAt: Date | null;
|
totpEnabledAt: Date | null;
|
||||||
|
mfaGraceStartedAt: Date | null;
|
||||||
|
mfaLastVerifiedAt: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UserEntity extends AggregateRoot<string> {
|
export class UserEntity extends AggregateRoot<string> {
|
||||||
@@ -39,6 +41,8 @@ export class UserEntity extends AggregateRoot<string> {
|
|||||||
private _totpEnabled: boolean;
|
private _totpEnabled: boolean;
|
||||||
private _totpBackupCodes: string[];
|
private _totpBackupCodes: string[];
|
||||||
private _totpEnabledAt: Date | null;
|
private _totpEnabledAt: Date | null;
|
||||||
|
private _mfaGraceStartedAt: Date | null;
|
||||||
|
private _mfaLastVerifiedAt: Date | null;
|
||||||
|
|
||||||
constructor(id: string, props: UserProps, createdAt?: Date, updatedAt?: Date) {
|
constructor(id: string, props: UserProps, createdAt?: Date, updatedAt?: Date) {
|
||||||
super(id, createdAt, updatedAt);
|
super(id, createdAt, updatedAt);
|
||||||
@@ -56,6 +60,8 @@ export class UserEntity extends AggregateRoot<string> {
|
|||||||
this._totpEnabled = props.totpEnabled;
|
this._totpEnabled = props.totpEnabled;
|
||||||
this._totpBackupCodes = props.totpBackupCodes;
|
this._totpBackupCodes = props.totpBackupCodes;
|
||||||
this._totpEnabledAt = props.totpEnabledAt;
|
this._totpEnabledAt = props.totpEnabledAt;
|
||||||
|
this._mfaGraceStartedAt = props.mfaGraceStartedAt;
|
||||||
|
this._mfaLastVerifiedAt = props.mfaLastVerifiedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
get email(): Email | null { return this._email; }
|
get email(): Email | null { return this._email; }
|
||||||
@@ -72,6 +78,8 @@ export class UserEntity extends AggregateRoot<string> {
|
|||||||
get totpEnabled(): boolean { return this._totpEnabled; }
|
get totpEnabled(): boolean { return this._totpEnabled; }
|
||||||
get totpBackupCodes(): string[] { return this._totpBackupCodes; }
|
get totpBackupCodes(): string[] { return this._totpBackupCodes; }
|
||||||
get totpEnabledAt(): Date | null { return this._totpEnabledAt; }
|
get totpEnabledAt(): Date | null { return this._totpEnabledAt; }
|
||||||
|
get mfaGraceStartedAt(): Date | null { return this._mfaGraceStartedAt; }
|
||||||
|
get mfaLastVerifiedAt(): Date | null { return this._mfaLastVerifiedAt; }
|
||||||
|
|
||||||
static createNew(
|
static createNew(
|
||||||
id: string,
|
id: string,
|
||||||
@@ -96,6 +104,8 @@ export class UserEntity extends AggregateRoot<string> {
|
|||||||
totpEnabled: false,
|
totpEnabled: false,
|
||||||
totpBackupCodes: [],
|
totpBackupCodes: [],
|
||||||
totpEnabledAt: null,
|
totpEnabledAt: null,
|
||||||
|
mfaGraceStartedAt: null,
|
||||||
|
mfaLastVerifiedAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
user.addDomainEvent(new UserRegisteredEvent(id, phone.value, role));
|
user.addDomainEvent(new UserRegisteredEvent(id, phone.value, role));
|
||||||
@@ -133,6 +143,8 @@ export class UserEntity extends AggregateRoot<string> {
|
|||||||
totpEnabled: false,
|
totpEnabled: false,
|
||||||
totpBackupCodes: [],
|
totpBackupCodes: [],
|
||||||
totpEnabledAt: null,
|
totpEnabledAt: null,
|
||||||
|
mfaGraceStartedAt: null,
|
||||||
|
mfaLastVerifiedAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
user.addDomainEvent(new UserRegisteredEvent(id, phone.value, role));
|
user.addDomainEvent(new UserRegisteredEvent(id, phone.value, role));
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { type DomainEvent } from '@modules/shared';
|
||||||
|
|
||||||
|
export class PhoneLoginOtpRequestedEvent implements DomainEvent {
|
||||||
|
readonly eventName = 'user.phone_login_otp_requested';
|
||||||
|
readonly occurredAt = new Date();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly aggregateId: string,
|
||||||
|
public readonly phone: string,
|
||||||
|
public readonly otpCode: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
28
apps/api/src/modules/auth/domain/mfa-policy.ts
Normal file
28
apps/api/src/modules/auth/domain/mfa-policy.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MFA enrolment policy — central source of truth for which roles require
|
||||||
|
* TOTP and how long the grace period lasts.
|
||||||
|
*
|
||||||
|
* Backed by `User.mfaGraceStartedAt` and `User.mfaLastVerifiedAt` columns.
|
||||||
|
*
|
||||||
|
* Policy summary:
|
||||||
|
* - On first login under enforcement, `mfaGraceStartedAt` is stamped.
|
||||||
|
* - For `MFA_GRACE_PERIOD_DAYS` after that timestamp, the user keeps full
|
||||||
|
* access but receives `mfa: 'grace'` in their JWT (UI nudges enrollment).
|
||||||
|
* - After grace expires, the JWT carries `mfa: 'enrollment_required'` and
|
||||||
|
* sensitive routes (admin guards) reject until the user enrols.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Roles for which TOTP is mandatory after the grace window expires. */
|
||||||
|
export const MFA_REQUIRED_ROLES: ReadonlyArray<UserRole> = ['ADMIN'];
|
||||||
|
|
||||||
|
/** Length of the grace window before MFA enrolment becomes mandatory. */
|
||||||
|
export const MFA_GRACE_PERIOD_DAYS = 14;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-auth window for "step-up" admin operations (e.g. user impersonation,
|
||||||
|
* mass actions). After this many minutes since `mfaLastVerifiedAt`, the
|
||||||
|
* admin re-auth interceptor must challenge again.
|
||||||
|
*/
|
||||||
|
export const MFA_REAUTH_WINDOW_MINUTES = 15;
|
||||||
@@ -12,4 +12,6 @@ export interface IUserRepository {
|
|||||||
updateMfaEnabled(userId: string, enabled: boolean, secret: string, backupCodes: string[]): Promise<void>;
|
updateMfaEnabled(userId: string, enabled: boolean, secret: string, backupCodes: string[]): Promise<void>;
|
||||||
updateMfaDisabled(userId: string): Promise<void>;
|
updateMfaDisabled(userId: string): Promise<void>;
|
||||||
updateBackupCodes(userId: string, backupCodes: string[]): Promise<void>;
|
updateBackupCodes(userId: string, backupCodes: string[]): Promise<void>;
|
||||||
|
updateMfaGraceStartedAt(userId: string, date: Date): Promise<void>;
|
||||||
|
updateMfaLastVerifiedAt(userId: string, date: Date): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ export { PhoneChangeRequestedEvent } from './domain/events/phone-change-requeste
|
|||||||
export { EmailChangedEvent } from './domain/events/email-changed.event';
|
export { EmailChangedEvent } from './domain/events/email-changed.event';
|
||||||
export { PhoneChangedEvent } from './domain/events/phone-changed.event';
|
export { PhoneChangedEvent } from './domain/events/phone-changed.event';
|
||||||
export { USER_REPOSITORY, IUserRepository } from './domain/repositories/user.repository';
|
export { USER_REPOSITORY, IUserRepository } from './domain/repositories/user.repository';
|
||||||
|
export { PasswordResetRequestedEvent } from './domain/events/password-reset-requested.event';
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { sign as jwtSign } from 'jsonwebtoken';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { verifyWithRotation, makeSecretOrKeyProvider } from '../utils/jwt-rotation';
|
||||||
|
|
||||||
|
const P = 'primary-secret-long-enough-for-hmac-signing-32!!';
|
||||||
|
const Q = 'previous-secret-long-enough-for-hmac-signing-32!';
|
||||||
|
const U = 'unknown-secret-long-enough-for-hmac-signing-32!!';
|
||||||
|
const O = { audience: 'goodgo-api', issuer: 'goodgo-platform', expiresIn: '15m' } as const;
|
||||||
|
const D = { sub: 'u1', phone: '0900000000', role: 'BUYER' };
|
||||||
|
|
||||||
|
describe('verifyWithRotation', () => {
|
||||||
|
it('succeeds with primary', () => { expect(verifyWithRotation(jwtSign(D, P, O), P, undefined)).toMatchObject(D); });
|
||||||
|
it('falls back to previous', () => { expect(verifyWithRotation(jwtSign(D, Q, O), P, Q)).toMatchObject(D); });
|
||||||
|
it('null when both fail', () => { expect(verifyWithRotation(jwtSign(D, U, O), P, Q)).toBeNull(); });
|
||||||
|
it('null without previous', () => { expect(verifyWithRotation(jwtSign(D, U, O), P, undefined)).toBeNull(); });
|
||||||
|
it('null for expired', () => { expect(verifyWithRotation(jwtSign(D, P, { ...O, expiresIn: '-1s' }), P, undefined)).toBeNull(); });
|
||||||
|
it('null for wrong audience', () => { expect(verifyWithRotation(jwtSign(D, P, { ...O, audience: 'x' }), P, undefined)).toBeNull(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('makeSecretOrKeyProvider', () => {
|
||||||
|
const call = (p: ReturnType<typeof makeSecretOrKeyProvider>, t: string) =>
|
||||||
|
new Promise<{ err: Error | null; secret?: string }>((r) => p({}, t, (e, s) => r({ err: e, secret: s })));
|
||||||
|
|
||||||
|
it('returns primary for primary-signed', async () => { const r = await call(makeSecretOrKeyProvider(P, Q), jwtSign(D, P, O)); expect(r.secret).toBe(P); });
|
||||||
|
it('returns previous for previous-signed', async () => { const r = await call(makeSecretOrKeyProvider(P, Q), jwtSign(D, Q, O)); expect(r.secret).toBe(Q); });
|
||||||
|
it('returns primary when both fail', async () => { const r = await call(makeSecretOrKeyProvider(P, Q), jwtSign(D, U, O)); expect(r.secret).toBe(P); });
|
||||||
|
});
|
||||||
@@ -160,6 +160,8 @@ describe('LocalStrategy', () => {
|
|||||||
phone: '+84912345678',
|
phone: '+84912345678',
|
||||||
role: 'BUYER',
|
role: 'BUYER',
|
||||||
isMfaRequired: false,
|
isMfaRequired: false,
|
||||||
|
totpEnabled: false,
|
||||||
|
mfaGraceStartedAt: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,158 +1,61 @@
|
|||||||
|
import { sign as jwtSign } from 'jsonwebtoken';
|
||||||
import { type IRefreshTokenRepository, type RefreshTokenRecord } from '../../domain/repositories/refresh-token.repository';
|
import { type IRefreshTokenRepository, type RefreshTokenRecord } from '../../domain/repositories/refresh-token.repository';
|
||||||
import { TokenService } from '../services/token.service';
|
import { TokenService } from '../services/token.service';
|
||||||
|
|
||||||
|
const PRIMARY_SECRET = 'primary-secret-that-is-long-enough-for-tests-32chars!';
|
||||||
|
const PREVIOUS_SECRET = 'previous-secret-that-is-long-enough-for-tests-32chars!';
|
||||||
|
const JWT_SIGN_OPTS = { audience: 'goodgo-api', issuer: 'goodgo-platform', expiresIn: '15m' } as const;
|
||||||
|
|
||||||
describe('TokenService', () => {
|
describe('TokenService', () => {
|
||||||
let service: TokenService;
|
let service: TokenService;
|
||||||
let mockJwtService: { sign: ReturnType<typeof vi.fn>; verify: ReturnType<typeof vi.fn> };
|
let mockJwtService: { sign: ReturnType<typeof vi.fn>; verify: ReturnType<typeof vi.fn> };
|
||||||
let mockRefreshTokenRepo: { [K in keyof IRefreshTokenRepository]: ReturnType<typeof vi.fn> };
|
let mockRefreshTokenRepo: { [K in keyof IRefreshTokenRepository]: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
const payload = { sub: 'user-1', phone: '0912345678', role: 'BUYER' };
|
const payload = { sub: 'user-1', phone: '0912345678', role: 'BUYER' };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockJwtService = {
|
process.env['JWT_SECRET'] = PRIMARY_SECRET;
|
||||||
sign: vi.fn().mockReturnValue('signed-jwt'),
|
delete process.env['JWT_SECRET_PREVIOUS'];
|
||||||
verify: vi.fn(),
|
mockJwtService = { sign: vi.fn().mockReturnValue('signed-jwt'), verify: vi.fn() };
|
||||||
};
|
mockRefreshTokenRepo = { create: vi.fn().mockResolvedValue({} as RefreshTokenRecord), findByToken: vi.fn(), revokeByFamily: vi.fn().mockResolvedValue(undefined), revokeAllForUser: vi.fn().mockResolvedValue(undefined), deleteExpired: vi.fn() };
|
||||||
mockRefreshTokenRepo = {
|
service = new TokenService(mockJwtService as any, mockRefreshTokenRepo as any);
|
||||||
create: vi.fn().mockResolvedValue({} as RefreshTokenRecord),
|
|
||||||
findByToken: vi.fn(),
|
|
||||||
revokeByFamily: vi.fn().mockResolvedValue(undefined),
|
|
||||||
revokeAllForUser: vi.fn().mockResolvedValue(undefined),
|
|
||||||
deleteExpired: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
service = new TokenService(
|
|
||||||
mockJwtService as any,
|
|
||||||
mockRefreshTokenRepo as any,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('generateTokenPair', () => {
|
describe('generateTokenPair', () => {
|
||||||
it('returns access token, refresh token with family prefix, and expiresIn', async () => {
|
it('returns access token, refresh token with family prefix, and expiresIn', async () => {
|
||||||
const result = await service.generateTokenPair(payload);
|
const result = await service.generateTokenPair(payload);
|
||||||
|
|
||||||
expect(result.accessToken).toBe('signed-jwt');
|
expect(result.accessToken).toBe('signed-jwt');
|
||||||
expect(result.refreshToken).toContain('.');
|
expect(result.refreshToken).toContain('.');
|
||||||
expect(result.expiresIn).toBe(900);
|
expect(result.expiresIn).toBe(900);
|
||||||
expect(mockJwtService.sign).toHaveBeenCalledWith(payload);
|
|
||||||
expect(mockRefreshTokenRepo.create).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
userId: 'user-1',
|
|
||||||
revokedAt: null,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates refresh token record with 30-day expiry', async () => {
|
it('creates refresh token record with 30-day expiry', async () => {
|
||||||
await service.generateTokenPair(payload);
|
await service.generateTokenPair(payload);
|
||||||
|
const expiresAt = mockRefreshTokenRepo.create.mock.calls[0][0].expiresAt as Date;
|
||||||
const createCall = mockRefreshTokenRepo.create.mock.calls[0][0];
|
const daysDiff = Math.round((expiresAt.getTime() - Date.now()) / 86400000);
|
||||||
const expiresAt = createCall.expiresAt as Date;
|
|
||||||
const now = new Date();
|
|
||||||
const daysDiff = Math.round((expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
|
||||||
expect(daysDiff).toBeGreaterThanOrEqual(29);
|
expect(daysDiff).toBeGreaterThanOrEqual(29);
|
||||||
expect(daysDiff).toBeLessThanOrEqual(31);
|
expect(daysDiff).toBeLessThanOrEqual(31);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('rotateRefreshToken', () => {
|
describe('rotateRefreshToken', () => {
|
||||||
const makeExistingToken = (overrides?: Partial<RefreshTokenRecord>): RefreshTokenRecord => ({
|
const makeTok = (o?: Partial<RefreshTokenRecord>): RefreshTokenRecord => ({ id: 'rt-1', userId: 'user-1', token: 'h', family: 'old-family', expiresAt: new Date(Date.now() + 86400000), revokedAt: null, createdAt: new Date(), ...o });
|
||||||
id: 'rt-1',
|
it('rotates valid token', async () => { mockRefreshTokenRepo.findByToken.mockResolvedValue(makeTok()); mockRefreshTokenRepo.create.mockResolvedValue({} as RefreshTokenRecord); const r = await service.rotateRefreshToken('old-family.raw'); expect(r).not.toBeNull(); expect(r!.userId).toBe('user-1'); });
|
||||||
userId: 'user-1',
|
it('null for malformed', async () => { expect(await service.rotateRefreshToken('nodot')).toBeNull(); });
|
||||||
token: 'hashed-token',
|
it('null + revoke when not found', async () => { mockRefreshTokenRepo.findByToken.mockResolvedValue(null); expect(await service.rotateRefreshToken('f.t')).toBeNull(); expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalledWith('f'); });
|
||||||
family: 'old-family',
|
it('null when revoked', async () => { mockRefreshTokenRepo.findByToken.mockResolvedValue(makeTok({ revokedAt: new Date() })); expect(await service.rotateRefreshToken('old-family.t')).toBeNull(); });
|
||||||
expiresAt: new Date(Date.now() + 86400000),
|
it('null when expired', async () => { mockRefreshTokenRepo.findByToken.mockResolvedValue(makeTok({ expiresAt: new Date(Date.now() - 86400000) })); expect(await service.rotateRefreshToken('old-family.t')).toBeNull(); });
|
||||||
revokedAt: null,
|
it('null for empty family', async () => { expect(await service.rotateRefreshToken('.raw')).toBeNull(); });
|
||||||
createdAt: new Date(),
|
it('null for empty raw', async () => { expect(await service.rotateRefreshToken('fam.')).toBeNull(); });
|
||||||
...overrides,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rotates valid token: revokes old family, creates new token', async () => {
|
describe('generateAccessToken', () => { it('delegates to jwtService.sign', () => { expect(service.generateAccessToken(payload)).toBe('signed-jwt'); }); });
|
||||||
mockRefreshTokenRepo.findByToken.mockResolvedValue(makeExistingToken());
|
describe('revokeAllUserTokens', () => { it('revokes', async () => { await service.revokeAllUserTokens('user-1'); expect(mockRefreshTokenRepo.revokeAllForUser).toHaveBeenCalledWith('user-1'); }); });
|
||||||
mockRefreshTokenRepo.create.mockResolvedValue({} as RefreshTokenRecord);
|
|
||||||
|
|
||||||
const result = await service.rotateRefreshToken('old-family.raw-token-hex');
|
|
||||||
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
expect(result!.userId).toBe('user-1');
|
|
||||||
expect(result!.refreshToken).toContain('.');
|
|
||||||
expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalledWith('old-family');
|
|
||||||
expect(mockRefreshTokenRepo.create).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for malformed token (no dot separator)', async () => {
|
|
||||||
const result = await service.rotateRefreshToken('no-dot-separator');
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null and revokes family when token not found (reuse attack)', async () => {
|
|
||||||
mockRefreshTokenRepo.findByToken.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const result = await service.rotateRefreshToken('suspect-family.unknown-token');
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalledWith('suspect-family');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null and revokes family when token is already revoked', async () => {
|
|
||||||
mockRefreshTokenRepo.findByToken.mockResolvedValue(
|
|
||||||
makeExistingToken({ revokedAt: new Date() }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await service.rotateRefreshToken('old-family.revoked-token');
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null and revokes family when token is expired', async () => {
|
|
||||||
mockRefreshTokenRepo.findByToken.mockResolvedValue(
|
|
||||||
makeExistingToken({ expiresAt: new Date(Date.now() - 86400000) }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await service.rotateRefreshToken('old-family.expired-token');
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for empty family segment', async () => {
|
|
||||||
const result = await service.rotateRefreshToken('.some-raw-token');
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for empty raw token segment', async () => {
|
|
||||||
const result = await service.rotateRefreshToken('some-family.');
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('generateAccessToken', () => {
|
|
||||||
it('delegates to jwtService.sign', () => {
|
|
||||||
const token = service.generateAccessToken(payload);
|
|
||||||
expect(token).toBe('signed-jwt');
|
|
||||||
expect(mockJwtService.sign).toHaveBeenCalledWith(payload);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('revokeAllUserTokens', () => {
|
|
||||||
it('revokes all tokens for a user', async () => {
|
|
||||||
await service.revokeAllUserTokens('user-1');
|
|
||||||
expect(mockRefreshTokenRepo.revokeAllForUser).toHaveBeenCalledWith('user-1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('verifyAccessToken', () => {
|
describe('verifyAccessToken', () => {
|
||||||
it('returns decoded payload for valid token', () => {
|
function svc(p: string, q?: string) { const o = process.env['JWT_SECRET']; const oq = process.env['JWT_SECRET_PREVIOUS']; process.env['JWT_SECRET'] = p; if (q) process.env['JWT_SECRET_PREVIOUS'] = q; else delete process.env['JWT_SECRET_PREVIOUS']; const s = new TokenService(mockJwtService as any, mockRefreshTokenRepo as any); if (o) process.env['JWT_SECRET'] = o; if (oq) process.env['JWT_SECRET_PREVIOUS'] = oq; else delete process.env['JWT_SECRET_PREVIOUS']; return s; }
|
||||||
mockJwtService.verify.mockReturnValue(payload);
|
it('primary succeeds', () => { expect(service.verifyAccessToken(jwtSign(payload, PRIMARY_SECRET, JWT_SIGN_OPTS))).toMatchObject(payload); });
|
||||||
const result = service.verifyAccessToken('valid-jwt');
|
it('fallback to previous', () => { expect(svc(PRIMARY_SECRET, PREVIOUS_SECRET).verifyAccessToken(jwtSign(payload, PREVIOUS_SECRET, JWT_SIGN_OPTS))).toMatchObject(payload); });
|
||||||
expect(result).toEqual(payload);
|
it('null when both fail', () => { expect(svc(PRIMARY_SECRET, PREVIOUS_SECRET).verifyAccessToken(jwtSign(payload, 'unknown-secret-that-is-long-enough-for-test!!!', JWT_SIGN_OPTS))).toBeNull(); });
|
||||||
});
|
it('null for garbage', () => { expect(service.verifyAccessToken('garbage')).toBeNull(); });
|
||||||
|
it('null for expired', () => { expect(service.verifyAccessToken(jwtSign(payload, PRIMARY_SECRET, { ...JWT_SIGN_OPTS, expiresIn: '-1s' }))).toBeNull(); });
|
||||||
it('returns null for invalid token', () => {
|
|
||||||
mockJwtService.verify.mockImplementation(() => { throw new Error('invalid'); });
|
|
||||||
const result = service.verifyAccessToken('bad-jwt');
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -123,6 +123,14 @@ export class PrismaUserRepository implements IUserRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateMfaGraceStartedAt(userId: string, date: Date): Promise<void> {
|
||||||
|
await this.prisma.user.update({ where: { id: userId }, data: { mfaGraceStartedAt: date } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMfaLastVerifiedAt(userId: string, date: Date): Promise<void> {
|
||||||
|
await this.prisma.user.update({ where: { id: userId }, data: { mfaLastVerifiedAt: date } });
|
||||||
|
}
|
||||||
|
|
||||||
private toDomain(raw: PrismaUser): UserEntity {
|
private toDomain(raw: PrismaUser): UserEntity {
|
||||||
const phone = Phone.create(raw.phone).unwrap();
|
const phone = Phone.create(raw.phone).unwrap();
|
||||||
const email = raw.email ? Email.create(raw.email).unwrap() : null;
|
const email = raw.email ? Email.create(raw.email).unwrap() : null;
|
||||||
@@ -145,6 +153,8 @@ export class PrismaUserRepository implements IUserRepository {
|
|||||||
totpEnabled: raw.totpEnabled,
|
totpEnabled: raw.totpEnabled,
|
||||||
totpBackupCodes: raw.totpBackupCodes,
|
totpBackupCodes: raw.totpBackupCodes,
|
||||||
totpEnabledAt: raw.totpEnabledAt,
|
totpEnabledAt: raw.totpEnabledAt,
|
||||||
|
mfaGraceStartedAt: raw.mfaGraceStartedAt,
|
||||||
|
mfaLastVerifiedAt: raw.mfaLastVerifiedAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
return new UserEntity(raw.id, props, raw.createdAt, raw.updatedAt);
|
return new UserEntity(raw.id, props, raw.createdAt, raw.updatedAt);
|
||||||
|
|||||||
@@ -121,10 +121,13 @@ export class OAuthService {
|
|||||||
kycStatus: 'NONE',
|
kycStatus: 'NONE',
|
||||||
kycData: null,
|
kycData: null,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
deletedAt: null,
|
||||||
totpSecret: null,
|
totpSecret: null,
|
||||||
totpEnabled: false,
|
totpEnabled: false,
|
||||||
totpBackupCodes: [],
|
totpBackupCodes: [],
|
||||||
totpEnabledAt: null,
|
totpEnabledAt: null,
|
||||||
|
mfaGraceStartedAt: null,
|
||||||
|
mfaLastVerifiedAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.userRepo.save(user);
|
await this.userRepo.save(user);
|
||||||
|
|||||||
@@ -5,11 +5,25 @@ import {
|
|||||||
REFRESH_TOKEN_REPOSITORY,
|
REFRESH_TOKEN_REPOSITORY,
|
||||||
type IRefreshTokenRepository,
|
type IRefreshTokenRepository,
|
||||||
} from '../../domain/repositories/refresh-token.repository';
|
} from '../../domain/repositories/refresh-token.repository';
|
||||||
|
import { verifyWithRotation } from '../utils/jwt-rotation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MFA enrolment status carried inside the access-token JWT.
|
||||||
|
*
|
||||||
|
* - `none` — role does not require MFA, or user is enrolled and
|
||||||
|
* has just verified (`requiresMfa === true` flow).
|
||||||
|
* - `grace` — role requires MFA but the user is inside the
|
||||||
|
* enforcement grace window. UI nudges enrollment.
|
||||||
|
* - `enrollment_required`— grace window has expired; backend guards on
|
||||||
|
* sensitive routes must reject and force enrollment.
|
||||||
|
*/
|
||||||
|
export type MfaClaim = 'none' | 'grace' | 'enrollment_required';
|
||||||
|
|
||||||
export interface JwtPayload {
|
export interface JwtPayload {
|
||||||
sub: string;
|
sub: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
mfa?: MfaClaim;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenPair {
|
export interface TokenPair {
|
||||||
@@ -26,102 +40,60 @@ export interface RotateResult {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class TokenService {
|
export class TokenService {
|
||||||
private readonly REFRESH_TOKEN_EXPIRY_DAYS = 30;
|
private readonly REFRESH_TOKEN_EXPIRY_DAYS = 30;
|
||||||
|
private readonly primarySecret: string;
|
||||||
|
private readonly previousSecret: string | undefined;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
@Inject(REFRESH_TOKEN_REPOSITORY)
|
@Inject(REFRESH_TOKEN_REPOSITORY)
|
||||||
private readonly refreshTokenRepo: IRefreshTokenRepository,
|
private readonly refreshTokenRepo: IRefreshTokenRepository,
|
||||||
) {}
|
) {
|
||||||
|
const secret = process.env['JWT_SECRET'];
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('JWT_SECRET environment variable is required');
|
||||||
|
}
|
||||||
|
this.primarySecret = secret;
|
||||||
|
this.previousSecret = process.env['JWT_SECRET_PREVIOUS'] || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
async generateTokenPair(payload: JwtPayload): Promise<TokenPair> {
|
async generateTokenPair(payload: JwtPayload): Promise<TokenPair> {
|
||||||
const accessToken = this.jwtService.sign(payload);
|
const accessToken = this.jwtService.sign(payload);
|
||||||
|
|
||||||
const rawRefreshToken = randomBytes(64).toString('hex');
|
const rawRefreshToken = randomBytes(64).toString('hex');
|
||||||
const hashedToken = this.hashToken(rawRefreshToken);
|
const hashedToken = this.hashToken(rawRefreshToken);
|
||||||
const family = randomBytes(16).toString('hex');
|
const family = randomBytes(16).toString('hex');
|
||||||
|
|
||||||
const expiresAt = new Date();
|
const expiresAt = new Date();
|
||||||
expiresAt.setDate(expiresAt.getDate() + this.REFRESH_TOKEN_EXPIRY_DAYS);
|
expiresAt.setDate(expiresAt.getDate() + this.REFRESH_TOKEN_EXPIRY_DAYS);
|
||||||
|
await this.refreshTokenRepo.create({ userId: payload.sub, token: hashedToken, family, expiresAt, revokedAt: null });
|
||||||
await this.refreshTokenRepo.create({
|
return { accessToken, refreshToken: `${family}.${rawRefreshToken}`, expiresIn: 900 };
|
||||||
userId: payload.sub,
|
|
||||||
token: hashedToken,
|
|
||||||
family,
|
|
||||||
expiresAt,
|
|
||||||
revokedAt: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
accessToken,
|
|
||||||
refreshToken: `${family}.${rawRefreshToken}`,
|
|
||||||
expiresIn: 900,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async rotateRefreshToken(refreshToken: string): Promise<RotateResult | null> {
|
async rotateRefreshToken(refreshToken: string): Promise<RotateResult | null> {
|
||||||
const dotIndex = refreshToken.indexOf('.');
|
const dotIndex = refreshToken.indexOf('.');
|
||||||
if (dotIndex === -1) return null;
|
if (dotIndex === -1) return null;
|
||||||
|
|
||||||
const family = refreshToken.substring(0, dotIndex);
|
const family = refreshToken.substring(0, dotIndex);
|
||||||
const rawToken = refreshToken.substring(dotIndex + 1);
|
const rawToken = refreshToken.substring(dotIndex + 1);
|
||||||
if (!family || !rawToken) return null;
|
if (!family || !rawToken) return null;
|
||||||
|
|
||||||
const hashedToken = this.hashToken(rawToken);
|
const hashedToken = this.hashToken(rawToken);
|
||||||
const existing = await this.refreshTokenRepo.findByToken(hashedToken);
|
const existing = await this.refreshTokenRepo.findByToken(hashedToken);
|
||||||
|
if (!existing) { await this.refreshTokenRepo.revokeByFamily(family); return null; }
|
||||||
if (!existing) {
|
if (existing.revokedAt || existing.expiresAt < new Date()) { await this.refreshTokenRepo.revokeByFamily(existing.family); return null; }
|
||||||
// Possible token reuse attack — revoke entire family
|
|
||||||
await this.refreshTokenRepo.revokeByFamily(family);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existing.revokedAt || existing.expiresAt < new Date()) {
|
|
||||||
await this.refreshTokenRepo.revokeByFamily(existing.family);
|
await this.refreshTokenRepo.revokeByFamily(existing.family);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Revoke all tokens in this family
|
|
||||||
await this.refreshTokenRepo.revokeByFamily(existing.family);
|
|
||||||
|
|
||||||
// Create new token in a new family
|
|
||||||
const newRawToken = randomBytes(64).toString('hex');
|
const newRawToken = randomBytes(64).toString('hex');
|
||||||
const newHashedToken = this.hashToken(newRawToken);
|
const newHashedToken = this.hashToken(newRawToken);
|
||||||
const newFamily = randomBytes(16).toString('hex');
|
const newFamily = randomBytes(16).toString('hex');
|
||||||
|
|
||||||
const expiresAt = new Date();
|
const expiresAt = new Date();
|
||||||
expiresAt.setDate(expiresAt.getDate() + this.REFRESH_TOKEN_EXPIRY_DAYS);
|
expiresAt.setDate(expiresAt.getDate() + this.REFRESH_TOKEN_EXPIRY_DAYS);
|
||||||
|
await this.refreshTokenRepo.create({ userId: existing.userId, token: newHashedToken, family: newFamily, expiresAt, revokedAt: null });
|
||||||
await this.refreshTokenRepo.create({
|
return { userId: existing.userId, refreshToken: `${newFamily}.${newRawToken}` };
|
||||||
userId: existing.userId,
|
|
||||||
token: newHashedToken,
|
|
||||||
family: newFamily,
|
|
||||||
expiresAt,
|
|
||||||
revokedAt: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
userId: existing.userId,
|
|
||||||
refreshToken: `${newFamily}.${newRawToken}`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
generateAccessToken(payload: JwtPayload): string {
|
generateAccessToken(payload: JwtPayload): string { return this.jwtService.sign(payload); }
|
||||||
return this.jwtService.sign(payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
async revokeAllUserTokens(userId: string): Promise<void> {
|
async revokeAllUserTokens(userId: string): Promise<void> { await this.refreshTokenRepo.revokeAllForUser(userId); }
|
||||||
await this.refreshTokenRepo.revokeAllForUser(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
verifyAccessToken(token: string): JwtPayload | null {
|
verifyAccessToken(token: string): JwtPayload | null {
|
||||||
try {
|
return verifyWithRotation<JwtPayload>(token, this.primarySecret, this.previousSecret);
|
||||||
return this.jwtService.verify<JwtPayload>(token);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private hashToken(token: string): string {
|
private hashToken(token: string): string { return createHash('sha256').update(token).digest('hex'); }
|
||||||
return createHash('sha256').update(token).digest('hex');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { type Request } from 'express';
|
import { type Request } from 'express';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
|
||||||
import { PrismaService, RedisService } from '@modules/shared';
|
import { PrismaService, RedisService } from '@modules/shared';
|
||||||
import { type JwtPayload } from '../services/token.service';
|
import { type JwtPayload } from '../services/token.service';
|
||||||
|
import { makeSecretOrKeyProvider } from '../utils/jwt-rotation';
|
||||||
|
|
||||||
function extractJwtFromCookieOrHeader(req: Request): string | null {
|
function extractJwtFromCookieOrHeader(req: Request): string | null {
|
||||||
const cookieToken = req.cookies?.['access_token'] as string | undefined;
|
const cookieToken = req.cookies?.['access_token'] as string | undefined;
|
||||||
@@ -12,88 +12,33 @@ function extractJwtFromCookieOrHeader(req: Request): string | null {
|
|||||||
return ExtractJwt.fromAuthHeaderAsBearerToken()(req);
|
return ExtractJwt.fromAuthHeaderAsBearerToken()(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cached user status — JSON encoded in Redis. */
|
interface CachedUserStatus { isActive: boolean; deletedAt: string | null; }
|
||||||
interface CachedUserStatus {
|
|
||||||
isActive: boolean;
|
|
||||||
deletedAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Redis key prefix for user status cache. Versioned so that a schema
|
|
||||||
* change can invalidate all stale entries by bumping the version.
|
|
||||||
*/
|
|
||||||
export const USER_STATUS_CACHE_PREFIX = 'auth:user_status:v1';
|
export const USER_STATUS_CACHE_PREFIX = 'auth:user_status:v1';
|
||||||
/** TTL for cached user status (seconds). */
|
|
||||||
export const USER_STATUS_CACHE_TTL_SECONDS = 60;
|
export const USER_STATUS_CACHE_TTL_SECONDS = 60;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor(
|
constructor(private readonly prisma: PrismaService, private readonly redis: RedisService) {
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly redis: RedisService,
|
|
||||||
) {
|
|
||||||
const jwtSecret = process.env['JWT_SECRET'];
|
const jwtSecret = process.env['JWT_SECRET'];
|
||||||
if (!jwtSecret) {
|
if (!jwtSecret) throw new Error('JWT_SECRET environment variable is required');
|
||||||
throw new Error('JWT_SECRET environment variable is required');
|
const previousSecret = process.env['JWT_SECRET_PREVIOUS'] || undefined;
|
||||||
}
|
super({ jwtFromRequest: extractJwtFromCookieOrHeader, ignoreExpiration: false, secretOrKeyProvider: makeSecretOrKeyProvider(jwtSecret, previousSecret), audience: 'goodgo-api', issuer: 'goodgo-platform' });
|
||||||
|
|
||||||
super({
|
|
||||||
jwtFromRequest: extractJwtFromCookieOrHeader,
|
|
||||||
ignoreExpiration: false,
|
|
||||||
secretOrKey: jwtSecret,
|
|
||||||
audience: 'goodgo-api',
|
|
||||||
issuer: 'goodgo-platform',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async validate(payload: JwtPayload): Promise<JwtPayload> {
|
async validate(payload: JwtPayload): Promise<JwtPayload> {
|
||||||
const status = await this.loadUserStatus(payload.sub);
|
const status = await this.loadUserStatus(payload.sub);
|
||||||
if (!status || !status.isActive || status.deletedAt !== null) {
|
if (!status || !status.isActive || status.deletedAt !== null) throw new UnauthorizedException('User account is inactive or deleted');
|
||||||
throw new UnauthorizedException('User account is inactive or deleted');
|
|
||||||
}
|
|
||||||
return { sub: payload.sub, phone: payload.phone, role: payload.role };
|
return { sub: payload.sub, phone: payload.phone, role: payload.role };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads user status from Redis cache if present, otherwise from DB and
|
|
||||||
* populates the cache with a 60 s TTL. Redis failures are non-fatal:
|
|
||||||
* we fall back to DB so a Redis outage cannot lock out all users.
|
|
||||||
*
|
|
||||||
* Returns null only when the user does not exist in the DB.
|
|
||||||
*/
|
|
||||||
private async loadUserStatus(userId: string): Promise<CachedUserStatus | null> {
|
private async loadUserStatus(userId: string): Promise<CachedUserStatus | null> {
|
||||||
const cacheKey = `${USER_STATUS_CACHE_PREFIX}:${userId}`;
|
const cacheKey = `${USER_STATUS_CACHE_PREFIX}:${userId}`;
|
||||||
|
if (this.redis.isAvailable()) { try { const cached = await this.redis.get(cacheKey); if (cached !== null) return JSON.parse(cached) as CachedUserStatus; } catch { /* swallow */ } }
|
||||||
if (this.redis.isAvailable()) {
|
const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { isActive: true, deletedAt: true } });
|
||||||
try {
|
|
||||||
const cached = await this.redis.get(cacheKey);
|
|
||||||
if (cached !== null) {
|
|
||||||
return JSON.parse(cached) as CachedUserStatus;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Swallow: degrade to DB on Redis read error.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await this.prisma.user.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
select: { isActive: true, deletedAt: true },
|
|
||||||
});
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
const status: CachedUserStatus = { isActive: user.isActive, deletedAt: user.deletedAt ? user.deletedAt.toISOString() : null };
|
||||||
const status: CachedUserStatus = {
|
if (this.redis.isAvailable()) { try { await this.redis.set(cacheKey, JSON.stringify(status), USER_STATUS_CACHE_TTL_SECONDS); } catch { /* swallow */ } }
|
||||||
isActive: user.isActive,
|
|
||||||
deletedAt: user.deletedAt ? user.deletedAt.toISOString() : null,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.redis.isAvailable()) {
|
|
||||||
try {
|
|
||||||
await this.redis.set(cacheKey, JSON.stringify(status), USER_STATUS_CACHE_TTL_SECONDS);
|
|
||||||
} catch {
|
|
||||||
// Swallow: cache population is best-effort.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export interface LocalStrategyResult {
|
|||||||
phone: string;
|
phone: string;
|
||||||
role: string;
|
role: string;
|
||||||
isMfaRequired: boolean;
|
isMfaRequired: boolean;
|
||||||
|
totpEnabled: boolean;
|
||||||
|
mfaGraceStartedAt: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -56,6 +58,8 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
|
|||||||
phone: user.phone.value,
|
phone: user.phone.value,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
isMfaRequired: user.totpEnabled,
|
isMfaRequired: user.totpEnabled,
|
||||||
|
totpEnabled: user.totpEnabled,
|
||||||
|
mfaGraceStartedAt: user.mfaGraceStartedAt,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DomainException) throw error;
|
if (error instanceof DomainException) throw error;
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { verify as jwtVerify, type JwtPayload as JsonWebTokenPayload } from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const JWT_VERIFY_OPTIONS = { audience: 'goodgo-api', issuer: 'goodgo-platform' } as const;
|
||||||
|
|
||||||
|
export function verifyWithRotation<T extends object = JsonWebTokenPayload>(
|
||||||
|
token: string, primarySecret: string, previousSecret: string | undefined,
|
||||||
|
): T | null {
|
||||||
|
try { return jwtVerify(token, primarySecret, JWT_VERIFY_OPTIONS) as T; } catch { /* primary failed */ }
|
||||||
|
if (previousSecret) { try { return jwtVerify(token, previousSecret, JWT_VERIFY_OPTIONS) as T; } catch { /* both failed */ } }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeSecretOrKeyProvider(
|
||||||
|
primarySecret: string, previousSecret: string | undefined,
|
||||||
|
): (request: unknown, rawJwtToken: string, done: (err: Error | null, secret?: string) => void) => void {
|
||||||
|
return (_request: unknown, rawJwtToken: string, done: (err: Error | null, secret?: string) => void) => {
|
||||||
|
try { jwtVerify(rawJwtToken, primarySecret, JWT_VERIFY_OPTIONS); return done(null, primarySecret); } catch { /* primary failed */ }
|
||||||
|
if (previousSecret) { try { jwtVerify(rawJwtToken, previousSecret, JWT_VERIFY_OPTIONS); return done(null, previousSecret); } catch { /* both failed */ } }
|
||||||
|
return done(null, primarySecret);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -62,7 +62,23 @@ import { RolesGuard } from '../guards/roles.guard';
|
|||||||
|
|
||||||
const IS_PRODUCTION = process.env['NODE_ENV'] === 'production';
|
const IS_PRODUCTION = process.env['NODE_ENV'] === 'production';
|
||||||
const IS_TEST = process.env['NODE_ENV'] === 'test';
|
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 ACCESS_TOKEN_MAX_AGE = 15 * 60 * 1000; // 15 minutes
|
||||||
const REFRESH_TOKEN_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days
|
const REFRESH_TOKEN_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
const AUTH_COOKIE_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 } })
|
@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)
|
@UseGuards(EndpointRateLimitGuard)
|
||||||
@Post('register')
|
@Post('register')
|
||||||
@ApiOperation({ summary: 'Register a new user' })
|
@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 } })
|
@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)
|
@UseGuards(EndpointRateLimitGuard, LocalAuthGuard)
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@ApiOperation({ summary: 'Login with phone and password' })
|
@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 } })
|
@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)
|
@UseGuards(EndpointRateLimitGuard)
|
||||||
@Post('forgot-password')
|
@Post('forgot-password')
|
||||||
@ApiOperation({
|
@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 } })
|
@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)
|
@UseGuards(EndpointRateLimitGuard)
|
||||||
@Post('reset-password')
|
@Post('reset-password')
|
||||||
@ApiOperation({ summary: 'Reset password using OTP code' })
|
@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 } })
|
@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)
|
@UseGuards(EndpointRateLimitGuard)
|
||||||
@Post('exchange-token')
|
@Post('exchange-token')
|
||||||
@ApiOperation({ summary: 'Exchange OAuth token pair for httpOnly cookies' })
|
@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 } })
|
@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)
|
@UseGuards(JwtAuthGuard, EndpointRateLimitGuard)
|
||||||
@Post('profile/verify-phone')
|
@Post('profile/verify-phone')
|
||||||
@ApiBearerAuth('JWT')
|
@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 } })
|
@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)
|
@UseGuards(JwtAuthGuard, EndpointRateLimitGuard)
|
||||||
@Post('profile/verify-email')
|
@Post('profile/verify-email')
|
||||||
@ApiBearerAuth('JWT')
|
@ApiBearerAuth('JWT')
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
|
Res,
|
||||||
|
StreamableFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CommandBus } from '@nestjs/cqrs';
|
import { CommandBus } from '@nestjs/cqrs';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiProduces } from '@nestjs/swagger';
|
||||||
|
import { Response } from 'express';
|
||||||
import { CancelUserDeletionCommand } from '../../application/commands/cancel-user-deletion/cancel-user-deletion.command';
|
import { CancelUserDeletionCommand } from '../../application/commands/cancel-user-deletion/cancel-user-deletion.command';
|
||||||
import { ExportUserDataCommand } from '../../application/commands/export-user-data/export-user-data.command';
|
import { ExportUserDataCommand } from '../../application/commands/export-user-data/export-user-data.command';
|
||||||
import { type UserDataExport } from '../../application/commands/export-user-data/export-user-data.handler';
|
import { type ExportUserDataResult } from '../../application/commands/export-user-data/export-user-data.handler';
|
||||||
import { ForceDeleteUserCommand } from '../../application/commands/force-delete-user/force-delete-user.command';
|
import { ForceDeleteUserCommand } from '../../application/commands/force-delete-user/force-delete-user.command';
|
||||||
import { RequestUserDeletionCommand } from '../../application/commands/request-user-deletion/request-user-deletion.command';
|
import { RequestUserDeletionCommand } from '../../application/commands/request-user-deletion/request-user-deletion.command';
|
||||||
import { type JwtPayload } from '../../infrastructure/services/token.service';
|
import { type JwtPayload } from '../../infrastructure/services/token.service';
|
||||||
@@ -58,13 +61,33 @@ export class UserDataController {
|
|||||||
@Get('me/export')
|
@Get('me/export')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth('JWT')
|
@ApiBearerAuth('JWT')
|
||||||
@ApiOperation({ summary: 'Export user data (GDPR Article 20)' })
|
@ApiProduces('application/json')
|
||||||
@ApiResponse({ status: 200, description: 'User data exported as JSON' })
|
@ApiOperation({
|
||||||
|
summary: 'Export user data (GDPR Article 20)',
|
||||||
|
description:
|
||||||
|
'Streams the full user data export as JSON. ' +
|
||||||
|
'Row cap (per collection) defaults to 10 000 rows; size cap defaults to 100 MB. ' +
|
||||||
|
'Both are configurable via EXPORT_ROW_CAP and EXPORT_SIZE_CAP_MB env vars.',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: 'User data exported as streaming JSON' })
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 413,
|
||||||
|
description: 'Export exceeds size cap — contact support for chunked export',
|
||||||
|
})
|
||||||
async exportData(
|
async exportData(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
): Promise<UserDataExport> {
|
@Res({ passthrough: true }) res: Response,
|
||||||
return this.commandBus.execute(new ExportUserDataCommand(user.sub));
|
): Promise<StreamableFile> {
|
||||||
|
const result: ExportUserDataResult = await this.commandBus.execute(
|
||||||
|
new ExportUserDataCommand(user.sub),
|
||||||
|
);
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Disposition',
|
||||||
|
`attachment; filename="user-data-${user.sub}.json"`,
|
||||||
|
);
|
||||||
|
return new StreamableFile(result.stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id/force')
|
@Delete(':id/force')
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- PrismaService & LoggerService are constructor-injected (NestJS DI)
|
|
||||||
import { DomainException, LoggerService, NotFoundException, PrismaService, ValidationException } from '@modules/shared';
|
import { DomainException, LoggerService, NotFoundException, PrismaService, ValidationException } from '@modules/shared';
|
||||||
import { PROPERTY_DOCUMENT_REPOSITORY, type IPropertyDocumentRepository } from '../../../domain/repositories/property-document.repository';
|
import { PROPERTY_DOCUMENT_REPOSITORY, type IPropertyDocumentRepository } from '../../../domain/repositories/property-document.repository';
|
||||||
import { ApproveDocumentCommand } from './approve-document.command';
|
import { ApproveDocumentCommand } from './approve-document.command';
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- LoggerService is constructor-injected (NestJS DI)
|
|
||||||
import { DomainException, LoggerService, NotFoundException, ValidationException } from '@modules/shared';
|
import { DomainException, LoggerService, NotFoundException, ValidationException } from '@modules/shared';
|
||||||
import { PROPERTY_DOCUMENT_REPOSITORY, type IPropertyDocumentRepository } from '../../../domain/repositories/property-document.repository';
|
import { PROPERTY_DOCUMENT_REPOSITORY, type IPropertyDocumentRepository } from '../../../domain/repositories/property-document.repository';
|
||||||
import { RejectDocumentCommand } from './reject-document.command';
|
import { RejectDocumentCommand } from './reject-document.command';
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Inject, InternalServerErrorException } from '@nestjs/common';
|
|||||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { createId } from '@paralleldrive/cuid2';
|
import { createId } from '@paralleldrive/cuid2';
|
||||||
import { PROPERTY_REPOSITORY, type IPropertyRepository, MEDIA_STORAGE_SERVICE, type IMediaStorageService } from '@modules/listings';
|
import { PROPERTY_REPOSITORY, type IPropertyRepository, MEDIA_STORAGE_SERVICE, type IMediaStorageService } from '@modules/listings';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- LoggerService is constructor-injected (NestJS DI requires runtime reference)
|
|
||||||
import { DomainException, LoggerService, NotFoundException, ValidationException } from '@modules/shared';
|
import { DomainException, LoggerService, NotFoundException, ValidationException } from '@modules/shared';
|
||||||
import { PropertyDocumentEntity } from '../../../domain/entities/property-document.entity';
|
import { PropertyDocumentEntity } from '../../../domain/entities/property-document.entity';
|
||||||
import { PROPERTY_DOCUMENT_REPOSITORY, type IPropertyDocumentRepository } from '../../../domain/repositories/property-document.repository';
|
import { PROPERTY_DOCUMENT_REPOSITORY, type IPropertyDocumentRepository } from '../../../domain/repositories/property-document.repository';
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { type PropertyDocument as PrismaPropertyDocument, type DocumentType, type DocumentVerificationStatus } from '@prisma/client';
|
import { type PropertyDocument as PrismaPropertyDocument, type DocumentType, type DocumentVerificationStatus } from '@prisma/client';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- PrismaService is constructor-injected (NestJS DI)
|
|
||||||
import { PrismaService } from '@modules/shared';
|
import { PrismaService } from '@modules/shared';
|
||||||
import { PropertyDocumentEntity, type PropertyDocumentProps } from '../../domain/entities/property-document.entity';
|
import { PropertyDocumentEntity, type PropertyDocumentProps } from '../../domain/entities/property-document.entity';
|
||||||
import { type IPropertyDocumentRepository } from '../../domain/repositories/property-document.repository';
|
import { type IPropertyDocumentRepository } from '../../domain/repositories/property-document.repository';
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- CommandBus & QueryBus are constructor-injected (NestJS DI)
|
|
||||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import {
|
import {
|
||||||
@@ -33,7 +32,6 @@ import { type PendingDocumentsResult } from '../../application/queries/get-pendi
|
|||||||
import { GetPendingDocumentsQuery } from '../../application/queries/get-pending-documents/get-pending-documents.query';
|
import { GetPendingDocumentsQuery } from '../../application/queries/get-pending-documents/get-pending-documents.query';
|
||||||
import { type PropertyDocumentDto } from '../../application/queries/get-property-documents/get-property-documents.handler';
|
import { type PropertyDocumentDto } from '../../application/queries/get-property-documents/get-property-documents.handler';
|
||||||
import { GetPropertyDocumentsQuery } from '../../application/queries/get-property-documents/get-property-documents.query';
|
import { GetPropertyDocumentsQuery } from '../../application/queries/get-property-documents/get-property-documents.query';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- DTOs are used at runtime by class-validator via @Body()
|
|
||||||
import { UploadDocumentDto, ApproveDocumentDto, RejectDocumentDto } from '../dto/upload-document.dto';
|
import { UploadDocumentDto, ApproveDocumentDto, RejectDocumentDto } from '../dto/upload-document.dto';
|
||||||
|
|
||||||
@ApiTags('documents')
|
@ApiTags('documents')
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
|
||||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
|
||||||
import { DomainException, LoggerService } from '@modules/shared';
|
import { DomainException, LoggerService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
SAVED_LISTING_REPOSITORY,
|
SAVED_LISTING_REPOSITORY,
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
|
||||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
|
||||||
import { DomainException, LoggerService } from '@modules/shared';
|
import { DomainException, LoggerService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
SAVED_LISTING_REPOSITORY,
|
SAVED_LISTING_REPOSITORY,
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
|
||||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
|
||||||
import { DomainException, LoggerService } from '@modules/shared';
|
import { DomainException, LoggerService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
SAVED_LISTING_REPOSITORY,
|
SAVED_LISTING_REPOSITORY,
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
|
||||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
|
||||||
import { DomainException, LoggerService } from '@modules/shared';
|
import { DomainException, LoggerService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
SAVED_LISTING_REPOSITORY,
|
SAVED_LISTING_REPOSITORY,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
|
||||||
import { ConflictException, NotFoundException, PrismaService } from '@modules/shared';
|
import { ConflictException, NotFoundException, PrismaService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
type FavoriteItem,
|
type FavoriteItem,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
|
||||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
@@ -16,15 +15,14 @@ import {
|
|||||||
ApiResponse,
|
ApiResponse,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
|
||||||
import { CurrentUser, JwtAuthGuard, type JwtPayload } from '@modules/auth';
|
import { CurrentUser, JwtAuthGuard, type JwtPayload } from '@modules/auth';
|
||||||
import { AddFavoriteCommand } from '../../application/commands/add-favorite/add-favorite.command';
|
import { AddFavoriteCommand } from '../../application/commands/add-favorite/add-favorite.command';
|
||||||
import { type AddFavoriteResult } from '../../application/commands/add-favorite/add-favorite.handler';
|
import { type AddFavoriteResult } from '../../application/commands/add-favorite/add-favorite.handler';
|
||||||
import { RemoveFavoriteCommand } from '../../application/commands/remove-favorite/remove-favorite.command';
|
import { RemoveFavoriteCommand } from '../../application/commands/remove-favorite/remove-favorite.command';
|
||||||
import { IsFavoritedQuery } from '../../application/queries/is-favorited/is-favorited.query';
|
|
||||||
import { type IsFavoritedResult } from '../../application/queries/is-favorited/is-favorited.handler';
|
import { type IsFavoritedResult } from '../../application/queries/is-favorited/is-favorited.handler';
|
||||||
import { ListFavoritesQuery } from '../../application/queries/list-favorites/list-favorites.query';
|
import { IsFavoritedQuery } from '../../application/queries/is-favorited/is-favorited.query';
|
||||||
import { type ListFavoritesResult } from '../../application/queries/list-favorites/list-favorites.handler';
|
import { type ListFavoritesResult } from '../../application/queries/list-favorites/list-favorites.handler';
|
||||||
|
import { ListFavoritesQuery } from '../../application/queries/list-favorites/list-favorites.query';
|
||||||
import { ListFavoritesDto } from '../dto/list-favorites.dto';
|
import { ListFavoritesDto } from '../dto/list-favorites.dto';
|
||||||
|
|
||||||
@ApiTags('favorites')
|
@ApiTags('favorites')
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
|
||||||
import { HealthCheck, HealthCheckService } from '@nestjs/terminus';
|
import { HealthCheck, HealthCheckService } from '@nestjs/terminus';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
|
||||||
import { PrismaHealthIndicator } from './infrastructure/prisma.health';
|
import { PrismaHealthIndicator } from './infrastructure/prisma.health';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
|
||||||
import { RedisHealthIndicator } from './infrastructure/redis.health';
|
import { RedisHealthIndicator } from './infrastructure/redis.health';
|
||||||
|
|
||||||
@Controller('health')
|
@Controller('health')
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { HealthCheckError, HealthIndicator, type HealthIndicatorResult } from '@nestjs/terminus';
|
import { HealthCheckError, HealthIndicator, type HealthIndicatorResult } from '@nestjs/terminus';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
|
||||||
import { PrismaService } from '@modules/shared';
|
import { PrismaService } from '@modules/shared';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { HealthCheckError, HealthIndicator, type HealthIndicatorResult } from '@nestjs/terminus';
|
import { HealthCheckError, HealthIndicator, type HealthIndicatorResult } from '@nestjs/terminus';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
|
||||||
import { RedisService } from '@modules/shared';
|
import { RedisService } from '@modules/shared';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ export class EstimateIndustrialRentHandler
|
|||||||
});
|
});
|
||||||
|
|
||||||
// If specific park requested, try to find it
|
// If specific park requested, try to find it
|
||||||
let specificPark = parkName
|
const specificPark = parkName
|
||||||
? provinceParks.find((p) => p.name.toLowerCase().includes(parkName.toLowerCase()))
|
? provinceParks.find((p) => p.name.toLowerCase().includes(parkName.toLowerCase()))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|||||||
@@ -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 { CreateIndustrialParkHandler } from './application/commands/create-industrial-park/create-industrial-park.handler';
|
||||||
import { DeleteIndustrialListingHandler } from './application/commands/delete-industrial-listing/delete-industrial-listing.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 { 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 { UpdateIndustrialListingHandler } from './application/commands/update-industrial-listing/update-industrial-listing.handler';
|
||||||
import { UpdateIndustrialParkHandler } from './application/commands/update-industrial-park/update-industrial-park.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';
|
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 { EstimateIndustrialRentHandler } from './application/queries/estimate-industrial-rent/estimate-industrial-rent.handler';
|
||||||
import { GetIndustrialListingHandler } from './application/queries/get-industrial-listing/get-industrial-listing.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 { 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 { IndustrialMarketHandler } from './application/queries/industrial-market/industrial-market.handler';
|
||||||
import { IndustrialParkStatsHandler } from './application/queries/industrial-park-stats/industrial-park-stats.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 { ListIndustrialListingsHandler } from './application/queries/list-industrial-listings/list-industrial-listings.handler';
|
||||||
import { ListIndustrialParksHandler } from './application/queries/list-industrial-parks/list-industrial-parks.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_LISTING_REPOSITORY } from './domain/repositories/industrial-listing.repository';
|
||||||
import { INDUSTRIAL_PARK_REPOSITORY } from './domain/repositories/industrial-park.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 { PrismaIndustrialListingRepository } from './infrastructure/repositories/prisma-industrial-listing.repository';
|
||||||
import { PrismaIndustrialParkRepository } from './infrastructure/repositories/prisma-industrial-park.repository';
|
import { PrismaIndustrialParkRepository } from './infrastructure/repositories/prisma-industrial-park.repository';
|
||||||
import { TypesenseIndustrialService } from './infrastructure/services/typesense-industrial.service';
|
import { TypesenseIndustrialService } from './infrastructure/services/typesense-industrial.service';
|
||||||
@@ -31,18 +36,22 @@ const CommandHandlers = [
|
|||||||
CreateIndustrialListingHandler,
|
CreateIndustrialListingHandler,
|
||||||
UpdateIndustrialListingHandler,
|
UpdateIndustrialListingHandler,
|
||||||
DeleteIndustrialListingHandler,
|
DeleteIndustrialListingHandler,
|
||||||
|
PromoteOsmParkHandler,
|
||||||
|
LockOsmParkHandler,
|
||||||
];
|
];
|
||||||
|
|
||||||
const QueryHandlers = [
|
const QueryHandlers = [
|
||||||
AnalyzeIndustrialLocationHandler,
|
AnalyzeIndustrialLocationHandler,
|
||||||
EstimateIndustrialRentHandler,
|
EstimateIndustrialRentHandler,
|
||||||
GetIndustrialParkHandler,
|
GetIndustrialParkHandler,
|
||||||
|
GetIndustrialParksByBboxHandler,
|
||||||
ListIndustrialParksHandler,
|
ListIndustrialParksHandler,
|
||||||
CompareIndustrialParksHandler,
|
CompareIndustrialParksHandler,
|
||||||
IndustrialParkStatsHandler,
|
IndustrialParkStatsHandler,
|
||||||
IndustrialMarketHandler,
|
IndustrialMarketHandler,
|
||||||
GetIndustrialListingHandler,
|
GetIndustrialListingHandler,
|
||||||
ListIndustrialListingsHandler,
|
ListIndustrialListingsHandler,
|
||||||
|
ListOsmPendingHandler,
|
||||||
];
|
];
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -52,6 +61,7 @@ const QueryHandlers = [
|
|||||||
{ provide: INDUSTRIAL_PARK_REPOSITORY, useClass: PrismaIndustrialParkRepository },
|
{ provide: INDUSTRIAL_PARK_REPOSITORY, useClass: PrismaIndustrialParkRepository },
|
||||||
{ provide: INDUSTRIAL_LISTING_REPOSITORY, useClass: PrismaIndustrialListingRepository },
|
{ provide: INDUSTRIAL_LISTING_REPOSITORY, useClass: PrismaIndustrialListingRepository },
|
||||||
TypesenseIndustrialService,
|
TypesenseIndustrialService,
|
||||||
|
OsmSyncCronService,
|
||||||
...CommandHandlers,
|
...CommandHandlers,
|
||||||
...QueryHandlers,
|
...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) {
|
if (params.ownerId) {
|
||||||
conditions.push(`"ownerId" = $${paramIndex++}`);
|
conditions.push(`"ownerId" = $${paramIndex++}`);
|
||||||
values.push(params.ownerId);
|
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) {
|
if (params.query) {
|
||||||
conditions.push(`(name ILIKE $${paramIndex} OR "nameEn" ILIKE $${paramIndex} OR developer ILIKE $${paramIndex} OR province ILIKE $${paramIndex})`);
|
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);
|
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[]>(
|
const rows = await this.prisma.$queryRawUnsafe<RawPark[]>(
|
||||||
`SELECT *, ST_Y(location::geometry) as lat, ST_X(location::geometry) as lng
|
`SELECT *, ST_Y(location::geometry) as lat, ST_X(location::geometry) as lng
|
||||||
FROM "IndustrialPark" WHERE ${where}
|
FROM "IndustrialPark" WHERE ${where}
|
||||||
ORDER BY "occupancyRate" DESC, "createdAt" DESC
|
ORDER BY "totalAreaHa" DESC NULLS LAST, "occupancyRate" DESC, "createdAt" DESC
|
||||||
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
|
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
|
||||||
...values, limit, offset,
|
...values, limit, offset,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,23 +2,27 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } f
|
|||||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { UserRole } from '@prisma/client';
|
import { UserRole } from '@prisma/client';
|
||||||
import { CurrentUser, JwtAuthGuard, Roles, RolesGuard } from '@modules/auth';
|
import { CurrentUser, JwtAuthGuard, Roles, RolesGuard, type JwtPayload } from '@modules/auth';
|
||||||
import type { JwtPayload } from '@modules/auth';
|
|
||||||
import { NotFoundException } from '@modules/shared';
|
import { NotFoundException } from '@modules/shared';
|
||||||
import { AnalyzeIndustrialLocationQuery } from '../../application/queries/analyze-industrial-location/analyze-industrial-location.query';
|
|
||||||
import { CreateIndustrialParkCommand } from '../../application/commands/create-industrial-park/create-industrial-park.command';
|
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 { DeleteIndustrialParkCommand } from '../../application/commands/delete-industrial-park/delete-industrial-park.command';
|
||||||
import { EstimateIndustrialRentQuery } from '../../application/queries/estimate-industrial-rent/estimate-industrial-rent.query';
|
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 { 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 { 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 { 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 { IndustrialMarketQuery } from '../../application/queries/industrial-market/industrial-market.query';
|
||||||
import { IndustrialParkStatsQuery } from '../../application/queries/industrial-park-stats/industrial-park-stats.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 { 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 { AnalyzeIndustrialLocationDto } from '../dto/analyze-industrial-location.dto';
|
||||||
import { CompareIndustrialParksDto } from '../dto/compare-industrial-parks.dto';
|
import { CompareIndustrialParksDto } from '../dto/compare-industrial-parks.dto';
|
||||||
import { CreateIndustrialParkDto } from '../dto/create-industrial-park.dto';
|
import { CreateIndustrialParkDto } from '../dto/create-industrial-park.dto';
|
||||||
import { EstimateIndustrialRentDto } from '../dto/estimate-industrial-rent.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 { SearchIndustrialParksDto } from '../dto/search-industrial-parks.dto';
|
||||||
import { UpdateIndustrialParkDto } from '../dto/update-industrial-park.dto';
|
import { UpdateIndustrialParkDto } from '../dto/update-industrial-park.dto';
|
||||||
|
|
||||||
@@ -51,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 ───────────────────────────────────────
|
// ── Park Operator endpoints ───────────────────────────────────────
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@@ -262,4 +284,72 @@ export class IndustrialParksController {
|
|||||||
);
|
);
|
||||||
return { success: true };
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
export class AnalyzeIndustrialLocationDto {
|
export class AnalyzeIndustrialLocationDto {
|
||||||
@ApiProperty({ example: 10.9, description: 'Vĩ độ' })
|
@ApiProperty({ example: 10.9, description: 'Vĩ độ' })
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsBoolean, IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsBoolean, IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
const INDUSTRIAL_PROPERTY_TYPES = [
|
const INDUSTRIAL_PROPERTY_TYPES = [
|
||||||
'industrial_land',
|
'industrial_land',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { CqrsModule } from '@nestjs/cqrs';
|
import { CqrsModule } from '@nestjs/cqrs';
|
||||||
import { InquiryCreatedToLeadListener } from './application/event-handlers/inquiry-created-to-lead.listener';
|
|
||||||
import { CreateLeadHandler } from './application/commands/create-lead/create-lead.handler';
|
import { CreateLeadHandler } from './application/commands/create-lead/create-lead.handler';
|
||||||
import { DeleteLeadHandler } from './application/commands/delete-lead/delete-lead.handler';
|
import { DeleteLeadHandler } from './application/commands/delete-lead/delete-lead.handler';
|
||||||
import { UpdateLeadStatusHandler } from './application/commands/update-lead-status/update-lead-status.handler';
|
import { UpdateLeadStatusHandler } from './application/commands/update-lead-status/update-lead-status.handler';
|
||||||
|
import { InquiryCreatedToLeadListener } from './application/event-handlers/inquiry-created-to-lead.listener';
|
||||||
import { GetLeadStatsHandler } from './application/queries/get-lead-stats/get-lead-stats.handler';
|
import { GetLeadStatsHandler } from './application/queries/get-lead-stats/get-lead-stats.handler';
|
||||||
import { GetLeadsByAgentHandler } from './application/queries/get-leads-by-agent/get-leads-by-agent.handler';
|
import { GetLeadsByAgentHandler } from './application/queries/get-leads-by-agent/get-leads-by-agent.handler';
|
||||||
import { LEAD_REPOSITORY } from './domain/repositories/lead.repository';
|
import { LEAD_REPOSITORY } from './domain/repositories/lead.repository';
|
||||||
|
|||||||
@@ -146,12 +146,14 @@ describe('UpdateListingStatusCommand', () => {
|
|||||||
'listing-1',
|
'listing-1',
|
||||||
'ACTIVE',
|
'ACTIVE',
|
||||||
'user-1',
|
'user-1',
|
||||||
|
'ADMIN',
|
||||||
'Đã xác minh thông tin',
|
'Đã xác minh thông tin',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(command.listingId).toBe('listing-1');
|
expect(command.listingId).toBe('listing-1');
|
||||||
expect(command.newStatus).toBe('ACTIVE');
|
expect(command.newStatus).toBe('ACTIVE');
|
||||||
expect(command.userId).toBe('user-1');
|
expect(command.userId).toBe('user-1');
|
||||||
|
expect(command.userRole).toBe('ADMIN');
|
||||||
expect(command.moderationNotes).toBe('Đã xác minh thông tin');
|
expect(command.moderationNotes).toBe('Đã xác minh thông tin');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { RecordPriceHistoryHandler } from '../event-handlers/record-price-history.handler';
|
|
||||||
import { ListingPriceChangedEvent } from '../../domain/events/listing-price-changed.event';
|
import { ListingPriceChangedEvent } from '../../domain/events/listing-price-changed.event';
|
||||||
|
import { RecordPriceHistoryHandler } from '../event-handlers/record-price-history.handler';
|
||||||
|
|
||||||
describe('RecordPriceHistoryHandler', () => {
|
describe('RecordPriceHistoryHandler', () => {
|
||||||
let handler: RecordPriceHistoryHandler;
|
let handler: RecordPriceHistoryHandler;
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ describe('UpdateListingStatusHandler', () => {
|
|||||||
const listing = createListing('listing-1', 'PENDING_REVIEW');
|
const listing = createListing('listing-1', 'PENDING_REVIEW');
|
||||||
mockListingRepo.findById.mockResolvedValue(listing);
|
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);
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
expect(result.status).toBe('ACTIVE');
|
expect(result.status).toBe('ACTIVE');
|
||||||
@@ -64,7 +64,7 @@ describe('UpdateListingStatusHandler', () => {
|
|||||||
const listing = createListing('listing-1', 'PENDING_REVIEW');
|
const listing = createListing('listing-1', 'PENDING_REVIEW');
|
||||||
mockListingRepo.findById.mockResolvedValue(listing);
|
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);
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
expect(result.status).toBe('REJECTED');
|
expect(result.status).toBe('REJECTED');
|
||||||
@@ -74,7 +74,7 @@ describe('UpdateListingStatusHandler', () => {
|
|||||||
const listing = createListing('listing-1', 'ACTIVE');
|
const listing = createListing('listing-1', 'ACTIVE');
|
||||||
mockListingRepo.findById.mockResolvedValue(listing);
|
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);
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
expect(result.status).toBe('SOLD');
|
expect(result.status).toBe('SOLD');
|
||||||
@@ -83,7 +83,7 @@ describe('UpdateListingStatusHandler', () => {
|
|||||||
it('throws NotFoundException for non-existent listing', async () => {
|
it('throws NotFoundException for non-existent listing', async () => {
|
||||||
mockListingRepo.findById.mockResolvedValue(null);
|
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');
|
await expect(handler.execute(command)).rejects.toThrow('Listing');
|
||||||
});
|
});
|
||||||
@@ -92,8 +92,28 @@ describe('UpdateListingStatusHandler', () => {
|
|||||||
const listing = createListing('listing-1', 'DRAFT');
|
const listing = createListing('listing-1', 'DRAFT');
|
||||||
mockListingRepo.findById.mockResolvedValue(listing);
|
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/);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user