Compare commits

..

1 Commits

Author SHA1 Message Date
Ho Ngoc Hai
99385d8263 feat(auth): validate KYC image URL hosts match MinIO bucket
Closes TEC-2725. Backend KYC presign + submit endpoints already landed in
8f8e20f; this adds the remaining acceptance criterion — host validation on
presigned URLs accepted via /auth/kyc/submit.

- Add IMediaStorageService.isTrustedUrl(url) — host+bucket check, supports
  MINIO_TRUSTED_HOSTS for CDN aliases
- SubmitKycHandler rejects imageUrls pointing outside our MinIO bucket
- Update handler specs with mock + new untrusted-host test

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-18 00:32:02 +07:00
1297 changed files with 29863 additions and 117282 deletions

View File

@@ -1,31 +0,0 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "web",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "@goodgo/web", "dev"],
"port": 3200
},
{
"name": "api",
"runtimeExecutable": "env",
"runtimeArgs": [
"NODE_OPTIONS=-r dotenv/config",
"DOTENV_CONFIG_PATH=../../.env",
"PORT=3201",
"pnpm",
"--filter",
"@goodgo/api",
"dev"
],
"port": 3201
},
{
"name": "ai-services",
"runtimeExecutable": "uvicorn",
"runtimeArgs": ["app.main:app", "--reload", "--port", "8000"],
"port": 8000
}
]
}

View File

@@ -32,19 +32,6 @@ REDIS_PORT=6379
REDIS_PASSWORD=CHANGE_ME_IN_PRODUCTION
REDIS_URL=redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}
# -----------------------------------------------------------------------------
# Redis — Queue (BullMQ)
#
# RFC-004 Phase 3: the async backbone (BullMQ) can point at a Redis instance
# separate from cache / throttler / websocket to keep hot cache traffic from
# starving queue operations. If unset, queue traffic falls back to the cache
# REDIS_* vars above (single-instance dev and small deployments keep working
# unchanged).
# -----------------------------------------------------------------------------
# REDIS_QUEUE_HOST=
# REDIS_QUEUE_PORT=
# REDIS_QUEUE_PASSWORD=
# -----------------------------------------------------------------------------
# Typesense
# -----------------------------------------------------------------------------
@@ -91,15 +78,6 @@ JWT_EXPIRES_IN=15m
JWT_REFRESH_SECRET=<generate with: openssl rand -base64 48>
JWT_REFRESH_EXPIRES_IN=7d
# -----------------------------------------------------------------------------
# Seed / E2E Accounts
# -----------------------------------------------------------------------------
# Required when running `pnpm db:seed`. Use a local/test-only value.
# Do not reuse this password for any real production admin account.
SEED_DEFAULT_PASSWORD=
BCRYPT_ROUNDS=12
E2E_ADMIN_PHONE=0876677771
# -----------------------------------------------------------------------------
# OAuth Providers
# -----------------------------------------------------------------------------
@@ -119,19 +97,11 @@ FRONTEND_URL=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:3000
WEB_PORT=3001
# Demo accounts must stay disabled in production. To enable in a local demo,
# provide a JSON array of {phone,name,role,badgeClass} and a temporary password.
NEXT_PUBLIC_ENABLE_DEMO_ACCOUNTS=false
NEXT_PUBLIC_DEMO_PASSWORD=
NEXT_PUBLIC_DEMO_ACCOUNTS=
# -----------------------------------------------------------------------------
# AI Service (Python/FastAPI)
# -----------------------------------------------------------------------------
AI_SERVICE_PORT=8000
AI_SERVICE_URL=http://localhost:8000
AI_SERVICE_API_KEY=<optional-in-dev-required-in-prod>
AI_CORS_ORIGINS=http://localhost:3000,http://localhost:3001
CLAUDE_API_KEY=
# -----------------------------------------------------------------------------
@@ -141,47 +111,23 @@ NEXT_PUBLIC_MAPBOX_TOKEN=
# -----------------------------------------------------------------------------
# Payment Gateways (VNPay, MoMo, ZaloPay)
# Leave empty if not using payment features.
#
# IMPORTANT: The values below default to SANDBOX endpoints. When deploying
# with NODE_ENV=production, swap each *_BASE_URL / *_ENDPOINT to the
# production URL and set *_TMN_CODE / *_PARTNER_CODE / *_APP_ID / secret
# values to live merchant credentials issued by the gateway. See
# docs/payment-go-live-checklist.md for the full cutover procedure.
# The API logs a startup warning if production mode is detected with
# sandbox-looking credentials.
# Leave empty if not using payment features
# -----------------------------------------------------------------------------
# VNPay — sandbox by default
# Production: VNPAY_BASE_URL=https://pay.vnpay.vn/vpcpay.html
# Production: VNPAY_API_URL=https://merchant.vnpay.vn/merchant_webapi/api/transaction
VNPAY_TMN_CODE=
VNPAY_HASH_SECRET=
VNPAY_BASE_URL=https://sandbox.vnpayment.vn/paymentv2/vpcpay.html
VNPAY_API_URL=https://sandbox.vnpayment.vn/merchant_webapi/api/transaction
# MoMo — sandbox by default
# Production: MOMO_ENDPOINT=https://payment.momo.vn/v2/gateway/api
MOMO_PARTNER_CODE=
MOMO_ACCESS_KEY=
MOMO_SECRET_KEY=
MOMO_ENDPOINT=https://test-payment.momo.vn/v2/gateway/api
# ZaloPay — sandbox by default
# Production: ZALOPAY_ENDPOINT=https://openapi.zalopay.vn/v2
ZALOPAY_APP_ID=
ZALOPAY_KEY1=
ZALOPAY_KEY2=
ZALOPAY_ENDPOINT=https://sb-openapi.zalopay.vn/v2
# Backend base URL used to construct IPN (server-to-server) callback URLs for
# MoMo (ipnUrl) and ZaloPay (callback_url). Must point to the API server, NOT
# the frontend. Example: https://api.goodgo.vn
# Individual gateway callback paths are appended automatically:
# MoMo → {PAYMENT_CALLBACK_BASE_URL}/api/v1/payments/callback/momo
# ZaloPay → {PAYMENT_CALLBACK_BASE_URL}/api/v1/payments/callback/zalopay
PAYMENT_CALLBACK_BASE_URL=https://api.goodgo.vn
BANK_TRANSFER_ACCOUNT_NUMBER=
BANK_TRANSFER_BANK_NAME=
BANK_TRANSFER_ACCOUNT_HOLDER=
@@ -238,10 +184,7 @@ SENTRY_PROJECT=
# Must be exactly 64 hex characters (32 bytes).
# 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=<generate with: openssl rand -hex 32>
KYC_ENCRYPTION_KEY_VERSION=1
# -----------------------------------------------------------------------------

View File

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

View File

@@ -70,89 +70,83 @@ jobs:
- name: Build
run: pnpm build
ai-services:
name: AI Services (Python) — Smoke
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: libs/ai-services
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python 3.12
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
cache-dependency-path: libs/ai-services/pyproject.toml
- name: Install dependencies (runtime + dev, no underthesea)
run: |
python -m pip install --upgrade pip
pip install \
"fastapi==0.115.0" \
"uvicorn[standard]==0.32.0" \
"xgboost==2.1.0" \
"numpy==1.26.4" \
"pydantic==2.9.0" \
"pydantic-settings==2.5.0" \
"httpx==0.27.0" \
"slowapi==0.1.9" \
"scikit-learn>=1.5.0" \
"pytest>=8.3.0" \
"pytest-asyncio>=0.24.0"
- name: Pytest (unit + health smoke)
env:
AI_CORS_ORIGINS: http://localhost:3000
run: pytest -q --ignore=tests/test_nlp.py
- name: Boot FastAPI + /health smoke
env:
AI_CORS_ORIGINS: http://localhost:3000
run: |
uvicorn app.main:app --host 127.0.0.1 --port 8000 &
PID=$!
for i in 1 2 3 4 5 6 7 8 9 10; do
if curl -sf http://127.0.0.1:8000/health; then
echo "health ok"
kill $PID
exit 0
fi
sleep 2
done
echo "health failed"
kill $PID || true
exit 1
- name: OpenAPI schema export (verifies /predict routes)
env:
AI_CORS_ORIGINS: http://localhost:3000
run: |
python - <<'PY'
import json, sys
from app.main import app
schema = app.openapi()
paths = schema.get("paths", {})
required = ["/avm/predict", "/avm/v2/predict", "/avm/industrial/predict", "/moderation/check", "/neighborhood/score"]
missing = [p for p in required if p not in paths]
if missing:
print("MISSING OpenAPI paths:", missing)
sys.exit(1)
print("OpenAPI paths OK:", sorted(paths.keys()))
PY
e2e:
name: E2E Tests
needs: ci
runs-on: ubuntu-latest
timeout-minutes: 45
timeout-minutes: 20
services:
postgres:
image: postgis/postgis:16-3.4
env:
POSTGRES_DB: goodgo_test
POSTGRES_USER: goodgo
POSTGRES_PASSWORD: goodgo_test_secret
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U goodgo -d goodgo_test"
--health-interval 10s
--health-timeout 5s
--health-retries 5
--health-start-period 30s
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
typesense:
image: typesense/typesense:27.1
ports:
- 8108:8108
env:
TYPESENSE_API_KEY: ts_ci_key
TYPESENSE_DATA_DIR: /data
options: >-
--health-cmd "curl -sf http://localhost:8108/health || exit 1"
--health-interval 10s
--health-timeout 5s
--health-retries 5
minio:
image: minio/minio:latest
ports:
- 9000:9000
env:
MINIO_ROOT_USER: ci_minio_user
MINIO_ROOT_PASSWORD: ci_minio_secret_key_32chars!!
options: >-
--health-cmd "curl -sf http://localhost:9000/minio/health/live || exit 1"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
CI: true
DATABASE_URL: postgresql://goodgo:goodgo_test_secret@localhost:5432/goodgo_test
REDIS_URL: redis://localhost:6379
TYPESENSE_URL: http://localhost:8108
TYPESENSE_HOST: localhost
TYPESENSE_PORT: 8108
TYPESENSE_API_KEY: ts_ci_key
MINIO_ENDPOINT: localhost
MINIO_PORT: 9000
MINIO_ACCESS_KEY: ci_minio_user
MINIO_SECRET_KEY: ci_minio_secret_key_32chars!!
MINIO_BUCKET: goodgo-uploads
NODE_ENV: test
JWT_SECRET: e2e-test-jwt-secret-key
JWT_REFRESH_SECRET: e2e-test-refresh-secret-key
VNPAY_TMN_CODE: TESTCODE
VNPAY_HASH_SECRET: TESTHASHSECRET
VNPAY_URL: https://sandbox.vnpayment.vn/paymentv2/vpcpay.html
VNPAY_RETURN_URL: http://localhost:3000/payment/return
steps:
- name: Checkout
@@ -170,12 +164,6 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Load E2E environment
run: awk 'NF && $1 !~ /^#/' .env.test >> "$GITHUB_ENV"
- name: Start CI service stack
run: docker compose --env-file .env.ci -f docker-compose.ci.yml up -d --wait
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
@@ -218,7 +206,3 @@ jobs:
name: playwright-traces
path: test-results/
retention-days: 7
- name: Stop CI service stack
if: always()
run: docker compose --env-file .env.ci -f docker-compose.ci.yml down -v

61
.github/workflows/codeql.yml vendored Normal file
View File

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

View File

@@ -23,53 +23,6 @@ env:
REGISTRY_URL: ghcr.io/${{ github.repository_owner }}
jobs:
deploy-config:
name: Check Deploy Configuration
runs-on: ubuntu-latest
outputs:
staging_ready: ${{ steps.check.outputs.staging_ready }}
production_ready: ${{ steps.check.outputs.production_ready }}
steps:
- name: Check required deploy secrets
id: check
env:
TARGET_ENV: ${{ inputs.environment }}
STAGING_HOST: ${{ secrets.STAGING_HOST }}
STAGING_USER: ${{ secrets.STAGING_USER }}
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
STAGING_URL: ${{ secrets.STAGING_URL }}
STAGING_API_URL: ${{ secrets.STAGING_API_URL }}
PRODUCTION_HOST: ${{ secrets.PRODUCTION_HOST }}
PRODUCTION_USER: ${{ secrets.PRODUCTION_USER }}
PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
PRODUCTION_URL: ${{ secrets.PRODUCTION_URL }}
PRODUCTION_API_URL: ${{ secrets.PRODUCTION_API_URL }}
run: |
STAGING_READY=false
PRODUCTION_READY=false
if [ -n "$STAGING_HOST" ] && [ -n "$STAGING_USER" ] && [ -n "$STAGING_SSH_KEY" ] && [ -n "$STAGING_URL" ] && [ -n "$STAGING_API_URL" ]; then
STAGING_READY=true
fi
if [ -n "$PRODUCTION_HOST" ] && [ -n "$PRODUCTION_USER" ] && [ -n "$PRODUCTION_SSH_KEY" ] && [ -n "$PRODUCTION_URL" ] && [ -n "$PRODUCTION_API_URL" ]; then
PRODUCTION_READY=true
fi
echo "staging_ready=$STAGING_READY" >> "$GITHUB_OUTPUT"
echo "production_ready=$PRODUCTION_READY" >> "$GITHUB_OUTPUT"
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ "$TARGET_ENV" = "staging" ] && [ "$STAGING_READY" != "true" ]; then
echo "Missing required staging deploy secrets; configure STAGING_HOST, STAGING_USER, STAGING_SSH_KEY, STAGING_URL, and STAGING_API_URL."
exit 1
fi
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ "$TARGET_ENV" = "production" ] && [ "$PRODUCTION_READY" != "true" ]; then
echo "Missing required production deploy secrets; configure PRODUCTION_HOST, PRODUCTION_USER, PRODUCTION_SSH_KEY, PRODUCTION_URL, and PRODUCTION_API_URL."
exit 1
fi
build-api:
name: Build API Image
runs-on: ubuntu-latest
@@ -201,14 +154,11 @@ jobs:
deploy-staging:
name: Deploy to Staging
needs: [deploy-config, build-api, build-web, build-ai]
needs: [build-api, build-web, build-ai]
if: >-
needs.deploy-config.outputs.staging_ready == 'true' &&
(
github.ref == 'refs/heads/develop' ||
(github.event_name == 'push' && github.ref == 'refs/heads/master') ||
(github.event_name == 'workflow_dispatch' && inputs.environment == 'staging')
)
github.ref == 'refs/heads/develop' ||
(github.event_name == 'push' && github.ref == 'refs/heads/master') ||
(github.event_name == 'workflow_dispatch' && inputs.environment == 'staging')
runs-on: ubuntu-latest
environment: staging
@@ -271,17 +221,17 @@ jobs:
[ "\$PREV_WEB" != "none" ] && docker tag "\$PREV_WEB" goodgo-web:rollback 2>/dev/null || true
[ "\$PREV_AI" != "none" ] && docker tag "\$PREV_AI" goodgo-ai-services:rollback 2>/dev/null || true
# Pull new images
docker compose -f docker-compose.prod.yml pull api web ai-services
# Apply migrations with the newly pulled API image before switching app containers.
docker compose -f docker-compose.prod.yml run --rm --no-deps api \
npx prisma migrate deploy --schema /app/prisma/schema.prisma
# Rolling update — zero downtime
docker compose -f docker-compose.prod.yml up -d --no-deps --wait api
docker compose -f docker-compose.prod.yml up -d --no-deps --wait web
docker compose -f docker-compose.prod.yml up -d --no-deps --wait ai-services
# Run database migrations
docker compose -f docker-compose.prod.yml exec -T api npx prisma migrate deploy
# NOTE: docker image prune is NOT run here — it runs after smoke tests pass
DEPLOY_SCRIPT
@@ -332,61 +282,13 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Run bash smoke tests
- name: Run smoke tests
env:
STAGING_URL: ${{ secrets.STAGING_URL }}
run: |
chmod +x scripts/smoke-test.sh
./scripts/smoke-test.sh "$STAGING_URL"
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Install Playwright system deps
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps chromium
- name: Run Playwright smoke tests (API)
env:
API_BASE_URL: ${{ secrets.STAGING_API_URL }}
CI: true
run: npx playwright test --project=smoke-api
- name: Run Playwright smoke tests (Web)
env:
API_BASE_URL: ${{ secrets.STAGING_API_URL }}
WEB_BASE_URL: ${{ secrets.STAGING_URL }}
CI: true
run: npx playwright test --project=smoke-web
- name: Upload Playwright smoke report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: smoke-report-staging-${{ github.run_id }}
path: playwright-report/
retention-days: 7
- name: Cleanup old images after successful smoke tests
if: success()
env:
@@ -444,11 +346,8 @@ jobs:
rollback-staging:
name: Rollback Staging
needs: [deploy-config, deploy-staging, smoke-test-staging]
if: >-
always() &&
needs.deploy-config.outputs.staging_ready == 'true' &&
(needs.deploy-staging.result == 'failure' || needs.smoke-test-staging.result == 'failure')
needs: [deploy-staging, smoke-test-staging]
if: failure()
runs-on: ubuntu-latest
environment: staging
@@ -515,11 +414,8 @@ jobs:
deploy-production:
name: Deploy to Production
needs: [deploy-config, build-api, build-web, build-ai]
if: >-
github.event_name == 'workflow_dispatch' &&
inputs.environment == 'production' &&
needs.deploy-config.outputs.production_ready == 'true'
needs: [build-api, build-web, build-ai]
if: inputs.environment == 'production'
runs-on: ubuntu-latest
environment: production
@@ -563,15 +459,13 @@ jobs:
docker compose -f docker-compose.prod.yml pull api web ai-services
# Apply migrations with the newly pulled API image before switching app containers.
docker compose -f docker-compose.prod.yml run --rm --no-deps api \
npx prisma migrate deploy --schema /app/prisma/schema.prisma
# Rolling update with health checks
docker compose -f docker-compose.prod.yml up -d --no-deps --wait api
docker compose -f docker-compose.prod.yml up -d --no-deps --wait web
docker compose -f docker-compose.prod.yml up -d --no-deps --wait ai-services
docker compose -f docker-compose.prod.yml exec -T api npx prisma migrate deploy
# NOTE: docker image prune is NOT run here — it runs after smoke tests pass
DEPLOY_SCRIPT
@@ -616,61 +510,13 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Run bash smoke tests
- name: Run smoke tests
env:
PRODUCTION_URL: ${{ secrets.PRODUCTION_URL }}
run: |
chmod +x scripts/smoke-test.sh
./scripts/smoke-test.sh "$PRODUCTION_URL"
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Install Playwright system deps
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps chromium
- name: Run Playwright smoke tests (API)
env:
API_BASE_URL: ${{ secrets.PRODUCTION_API_URL }}
CI: true
run: npx playwright test --project=smoke-api
- name: Run Playwright smoke tests (Web)
env:
API_BASE_URL: ${{ secrets.PRODUCTION_API_URL }}
WEB_BASE_URL: ${{ secrets.PRODUCTION_URL }}
CI: true
run: npx playwright test --project=smoke-web
- name: Upload Playwright smoke report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: smoke-report-production-${{ github.run_id }}
path: playwright-report/
retention-days: 14
- name: Cleanup old images after successful smoke tests
if: success()
env:
@@ -710,11 +556,8 @@ jobs:
rollback-production:
name: Rollback Production
needs: [deploy-config, deploy-production, smoke-test-production]
if: >-
always() &&
needs.deploy-config.outputs.production_ready == 'true' &&
(needs.deploy-production.result == 'failure' || needs.smoke-test-production.result == 'failure')
needs: [smoke-test-production]
if: failure()
runs-on: ubuntu-latest
environment: production

View File

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

View File

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

View File

@@ -1,103 +0,0 @@
name: Smoke Tests (Post-Deploy)
on:
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
default: 'staging'
type: choice
options:
- staging
- production
api_url:
description: 'API base URL (overrides default for env)'
required: false
type: string
web_url:
description: 'Web base URL (overrides default for env)'
required: false
type: string
workflow_call:
inputs:
environment:
required: false
type: string
default: 'staging'
api_url:
required: false
type: string
web_url:
required: false
type: string
concurrency:
group: smoke-${{ inputs.environment || 'staging' }}-${{ github.ref }}
cancel-in-progress: true
jobs:
smoke:
name: Smoke — ${{ inputs.environment || 'staging' }}
runs-on: ubuntu-latest
timeout-minutes: 10
env:
API_BASE_URL: ${{ inputs.api_url || (inputs.environment == 'production' && vars.PROD_API_URL) || vars.STAGING_API_URL || 'http://localhost:3001/api/v1/' }}
WEB_BASE_URL: ${{ inputs.web_url || (inputs.environment == 'production' && vars.PROD_WEB_URL) || vars.STAGING_WEB_URL || 'http://localhost:3000' }}
CI: true
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Install Playwright system deps
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps chromium
- name: Run smoke tests (API)
run: npx playwright test --project=smoke-api
env:
API_BASE_URL: ${{ env.API_BASE_URL }}
- name: Run smoke tests (Web)
run: npx playwright test --project=smoke-web
env:
WEB_BASE_URL: ${{ env.WEB_BASE_URL }}
API_BASE_URL: ${{ env.API_BASE_URL }}
- name: Upload smoke report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: smoke-report-${{ inputs.environment || 'staging' }}-${{ github.run_id }}
path: playwright-report/
retention-days: 7
- name: Notify failure
if: failure()
run: |
echo "::error::Smoke tests FAILED on ${{ inputs.environment || 'staging' }}. Check the uploaded playwright-report artifact for details."

8
.gitignore vendored
View File

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

View File

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

View File

@@ -1,299 +1,262 @@
# Nhật Ký Thay Đổi
# Changelog
Tất cả các thay đổi đáng chú ý của GoodGo Platform sẽ được ghi lại trong tệp này.
All notable changes to the GoodGo Platform will be documented in this file.
Định dạng dựa trên [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
và dự án này tuân theo [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### GOO-33 Documentation Sprint (2026-04-22)
### Added (CEO Audit Wave 13 — 2026-04-12)
- CEO audit routine (TEC-1915) — full codebase audit + project state review
- Plan document with 7-section report: audit summary, critical issues, priorities, recommendations
- 6 new subtasks created (TEC-1918 through TEC-1923) for Wave 13
- Updated PROJECT_TRACKER with Wave 13 tracking section
#### Đã hoàn thành
- QA_TRACKER.md — cập nhật test status baseline + Sprint 1-2 test plans
- CHANGELOG.md — cập nhật changelog lần cuối (2026-04-22)
- PROJECT_TRACKER.md — cập nhật GOO-33 status → in_progress
- CONTRIBUTING.md — thêm branching strategy, PR conventions, commit message format
- docs/ci-cd.md — tài liệu đầy đủ GitHub Actions pipeline (lint → typecheck → test → build)
- docs/onboarding.md — hướng dẫn setup dành cho developer mới
- docs/mcp-servers.md — tài liệu 3 MCP servers (search, analytics, valuation)
- docs/audits/ — curate từ ~103 → 12 canonical audit reports + archive old files
### QA Results (2026-04-12)
- Lint: PASS (0 errors)
- TypeScript: 7 errors in web test files (vitest types missing) — TEC-1918
- Unit Tests: 232 files, 1454 tests, ALL PASS
- Build: ALL 3 packages build successfully
- Git: Clean working tree
### GOO-2 Lead Orchestrator Audit (2026-04-22)
### Added
- CEO full audit & implementation plan (TEC-1882) — 8-part report covering architecture, quality, security
- 7 new subtasks created (TEC-1888 through TEC-1894) for Wave 11D-13
- Updated PROJECT_TRACKER with Waves 11D-13 subtask tracking
- Updated QA_TRACKER with 2026-04-11 test report (27 failing tests identified)
- Comprehensive audit reports: AUDIT_SUMMARY, COMPREHENSIVE_AUDIT, AUDIT_INDEX
#### Audit & Planning
- Kiểm tra toàn diện codebase: 51 findings (4 blockers, 24 high, 13 medium, 10 low)
- Nghiên cứu thị trường BĐS VN: 23 findings (3 P0, 10 P1, 8 P2, 1 P3)
- Ma trận đề xuất: 25 cải thiện (Nhóm A) + 20 tính năng mới (Nhóm B) + 10 docs gaps (Nhóm C)
- Tạo 32 subtasks (GOO-3 → GOO-34) phân theo 6 sprints
- Tạo QA_TRACKER.md, cập nhật PROJECT_TRACKER.md
### Identified (from CEO Audit 2026-04-11)
- 725 ESLint errors (712 auto-fixable) — TEC-1888
- TypeScript errors in web tests (json-ld.spec.tsx) — TEC-1888
- 27 failing rate limit guard tests — TEC-1889
- 3 incomplete API modules (health, metrics, mcp) — TEC-1890
- MCP servers are stubs (~50 lines each) — TEC-1891
- Only 6 web unit tests (need 50+) — TEC-1892
- No field-level PII encryption — TEC-1893
- No MFA for agent/admin accounts — TEC-1894
#### Đã sửa
- GOO-3: Fix double CSRF middleware — login/register/payment callbacks hoạt động (Sprint 1) ✅
### Previously Added
- CEO audit plan document with full improvement & feature matrix (TEC-1682)
- Wave 5 issues: npm vulnerability fixes, test coverage, Saved Searches, Dependabot
- PgBouncer connection pooling for production PostgreSQL
- SEO optimization — JSON-LD, dynamic sitemap, meta tags for listings
- API error codes reference documentation
- Security headers hardening across API and Web apps
- Multi-stage production Dockerfile for NestJS API
- Startup-time validation for JWT secrets (rejects placeholders)
- Per-type file size limits and 413 responses for media uploads
- Rate limiting and auth guard for MCP transport controller
- Async error handling for critical module handlers
- QueryErrorBoundary component with real map coordinates (web)
- GDPR-compliant user data deletion endpoint
- Listing search caching with @Cacheable decorator
- Auth + search i18n translations and filter-bar accessibility
#### Đang triển khai (Sprint 1 Blockers)
- GOO-4: UsageRecord atomic metering (fix quota bypass)
- GOO-5: Rate-limit POST /auth/exchange-token
- GOO-6: Fix MoMo IPN URL (tách ipnUrl khỏi redirectUrl)
- GOO-7: JWT validate user status (isActive + deletedAt)
### Fixed
- MCP transport controller now requires JWT authentication (BUG-004 resolved)
- 21 lint errors from GDPR/logger/caching commits
- Replaced `new Logger()` with DI LoggerService across modules
- CI workflow branch targets corrected from main to master
- Lint error and typecheck failures for MVP launch readiness
#### Phát hiện chính (P0 — Launch Blockers)
- Thiếu Phone-OTP login (phương thức auth chính ở VN)
- legalStatus là free-text, không phải enum (tín hiệu tin cậy #1)
- Typesense không hỗ trợ tìm kiếm dấu tiếng Việt
- Thiếu phòng trọ (ROOM_RENTAL) trong PropertyType enum
- Quận 2/9 đã bị xóa (→ Thủ Đức) nhưng vẫn hardcoded trong UI
### Đã thêm (CEO Audit Wave 13 — 2026-04-12)
- Quy trình kiểm tra CEO (TEC-1915) — kiểm tra toàn bộ codebase + xem xét trạng thái dự án
- Tài liệu kế hoạch với báo cáo 7 phần: tóm tắt kiểm tra, các vấn đề quan trọng, ưu tiên, khuyến nghị
- 6 subtask mới được tạo (TEC-1918 đến TEC-1923) cho Wave 13
- Cập nhật PROJECT_TRACKER với phần theo dõi Wave 13
### Kết Quả QA (2026-04-12)
- Lint: PASS (0 lỗi)
- TypeScript: 7 lỗi trong các tệp test web (thiếu kiểu vitest) — TEC-1918
- Kiểm thử đơn vị: 232 tệp, 1454 bài kiểm thử, TẤT CẢ ĐỀU PASS
- Build: TẤT CẢ 3 gói build thành công
- Git: Cây làm việc sạch
### Đã thêm
- Kiểm tra toàn diện CEO & kế hoạch triển khai (TEC-1882) — báo cáo 8 phần bao gồm kiến trúc, chất lượng, bảo mật
- 7 subtask mới được tạo (TEC-1888 đến TEC-1894) cho Wave 11D-13
- Cập nhật PROJECT_TRACKER với theo dõi subtask Waves 11D-13
- Cập nhật QA_TRACKER với báo cáo kiểm thử ngày 2026-04-11 (xác định 27 bài kiểm thử thất bại)
- Các báo cáo kiểm tra toàn diện: AUDIT_SUMMARY, COMPREHENSIVE_AUDIT, AUDIT_INDEX
### Đã xác định (từ CEO Audit 2026-04-11)
- 725 lỗi ESLint (712 có thể tự động sửa) — TEC-1888
- Lỗi TypeScript trong các bài kiểm thử web (json-ld.spec.tsx) — TEC-1888
- 27 bài kiểm thử rate limit guard thất bại — TEC-1889
- 3 module API chưa hoàn chỉnh (health, metrics, mcp) — TEC-1890
- Các MCP server chỉ là stub (~50 dòng mỗi cái) — TEC-1891
- Chỉ có 6 bài kiểm thử đơn vị web (cần 50+) — TEC-1892
- Không có mã hóa PII ở cấp độ trường — TEC-1893
- Không có MFA cho tài khoản agent/admin — TEC-1894
### Đã thêm trước đó
- Tài liệu kế hoạch kiểm tra CEO với ma trận cải tiến & tính năng đầy đủ (TEC-1682)
- Các vấn đề Wave 5: sửa lỗ hổng npm, độ phủ kiểm thử, Saved Searches, Dependabot
- Kết nối pool PgBouncer cho PostgreSQL môi trường production
- Tối ưu hóa SEO — JSON-LD, sitemap động, meta tags cho danh sách bất động sản
- Tài liệu tham khảo mã lỗi API
- Tăng cường tiêu đề bảo mật cho cả API và ứng dụng Web
- Dockerfile production đa giai đoạn cho NestJS API
- Kiểm tra giá trị JWT secret khi khởi động (từ chối giá trị giữ chỗ)
- Giới hạn kích thước tệp theo loại và phản hồi 413 cho tải lên media
- Rate limiting và auth guard cho MCP transport controller
- Xử lý lỗi bất đồng bộ cho các handler module quan trọng
- Component QueryErrorBoundary với tọa độ bản đồ thực tế (web)
- Endpoint xóa dữ liệu người dùng tuân thủ GDPR
- Cache kết quả tìm kiếm danh sách bất động sản với decorator @Cacheable
- Bản dịch i18n cho Auth + search và khả năng truy cập filter-bar
### Đã sửa
- MCP transport controller hiện yêu cầu xác thực JWT (BUG-004 đã giải quyết)
- 21 lỗi lint từ các commit GDPR/logger/caching
- Thay thế `new Logger()` bằng DI LoggerService xuyên suốt các module
- Đã sửa nhánh đích của CI workflow từ main sang master
- Lỗi lint và typecheck để chuẩn bị ra mắt MVP
### Đã thay đổi
- Tách các tệp lớn trong quá trình refactor logger
### Changed
- Split large files during logger refactor
---
## [1.4.0] - 2026-04-08
### Đã thêm
- Redis caching cho kiểm tra quota người dùng với xóa cache theo tiền tố
- Kiểm thử đơn vị tầng domain trên tất cả các module (auth, payments, subscriptions, admin, analytics, listings, notifications, reviews, search, metrics)
- Các endpoint health check (`/health`, `/health/db`, `/health/redis`) sử dụng `@nestjs/terminus`
- Giao diện Định giá Bất động sản với tích hợp AVM (Automated Valuation Model) trên web frontend
### Added
- Redis caching for user quota checks with prefix-based cache invalidation
- Domain layer unit tests across all modules (auth, payments, subscriptions, admin, analytics, listings, notifications, reviews, search, metrics)
- Health check endpoints (`/health`, `/health/db`, `/health/redis`) using `@nestjs/terminus`
- Property Valuation UI with AVM (Automated Valuation Model) integration on the web frontend
### Đã thay đổi
- Cải thiện cache service với các mẫu xóa theo tiền tố
- Nâng cao các handler truy vấn analytics với tầng caching
### Changed
- Improved cache service with prefix-based clearing patterns
- Enhanced analytics query handlers with caching layer
### Đã sửa
- Giải quyết các lỗi lint trên toàn bộ codebase
### Fixed
- Lint errors resolved across codebase
---
## [1.3.0] - 2026-03-28
### Đã thêm
- Hệ thống gửi thông báo hoàn chỉnh với email (Nodemailer + Handlebars), push (Firebase Cloud Messaging), và các kênh trong ứng dụng
- Trực quan hóa heatmap quận huyện bằng Mapbox và dashboard hiệu suất agent trên web frontend
- Module đánh giá với đầy đủ các endpoint CRUD, các handler CQRS, và value object đánh giá 1-5 sao
- Kiểm thử đơn vị cho các module analytics, metrics, notifications, payments search
- Cải thiện geo-search với truy vấn không gian PostGIS và các event handler listing-approved của Typesense
- Endpoint `/health` chuyên dụng với phản hồi timestamp
### Added
- Complete notification delivery system with email (Nodemailer + Handlebars), push (Firebase Cloud Messaging), and in-app channels
- Mapbox district heatmap visualization and agent performance dashboard on web frontend
- Reviews module with full CRUD endpoints, CQRS handlers, and 1-5 star rating value objects
- Unit tests for analytics, metrics, notifications, payments, and search modules
- Enhanced geo-search with PostGIS spatial queries and Typesense listing-approved event handlers
- Dedicated `/health` endpoint with timestamp response
### Đã thay đổi
- Refactor nội bộ cache service và các handler analytics để tăng độ tin cậy
### Changed
- Refactored cache service internals and analytics handlers for better reliability
### Đã sửa
- Thiếu các thuộc tính `AuthState` trong các mock kiểm thử web frontend
- Cải thiện quy trình E2E: bước Prisma generate, cache trình duyệt, trace artifacts
### Fixed
- Missing `AuthState` properties in web frontend test mocks
- E2E workflow improvements: Prisma generate step, browser cache, trace artifacts
---
## [1.2.0] - 2026-03-20
### Đã thêm
- Tích hợp React Query cho data fetching với UX thử lại khi lỗi
- Nút chuyển đổi dark mode cho web frontend
- Tầng Redis caching cho các đường dẫn hot của search analytics
- Pipeline NLP tiếng Việt (Underthesea) để phân tích mô tả bất động sản trong AI services
- `MetricsService`, `HttpMetricsInterceptor` Prometheus, và các hằng số metric tùy chỉnh
- Trang Agent Profile, xác minh KYC, Subscription, và bảng điều khiển Payment trên web frontend
- Kiểm thử đơn vị cho các MCP server (tìm kiếm bất động sản, phân tích thị trường, định giá)
- Kiểm thử đơn vị cho các hàm kiểm tra và tiện ích web frontend
### Added
- React Query integration for data fetching with error retry UX
- Dark mode toggle for web frontend
- Redis caching layer for search and analytics hot paths
- Vietnamese NLP pipeline (Underthesea) for property description analysis in AI services
- Prometheus `MetricsService`, `HttpMetricsInterceptor`, and custom metric constants
- Agent Profile, KYC verification, Subscription, and Payment dashboard pages on web frontend
- Unit tests for MCP servers (property search, market analytics, valuation)
- Unit tests for web frontend validations and utility functions
### Đã sửa
- Xóa thông tin xác thực MinIO được mã hóa cứng; thêm hỗ trợ presigned URL cho tải lên media
- Áp dụng kiểm tra JWT secret cho tất cả môi trường (không chỉ production)
- Thêm chỉ mục `Review.userId` còn thiếu để tăng hiệu suất truy vấn FK
### Fixed
- Removed MinIO hardcoded credentials; added presigned URL support for media uploads
- JWT secret enforcement in all environments (not just production)
- Added missing `Review.userId` index for FK query performance
---
## [1.1.0] - 2026-03-12
### Đã thêm
- Dịch vụ phát hiện danh sách bất động sản trùng lặp để ngăn chặn các bài đăng dư thừa
- Giới hạn quota subscription với giới hạn tính năng theo gói và đo lường mức sử dụng
- Các chiến lược OAuth backend Google và Zalo cho đăng nhập mạng xã hội
- 58 bài kiểm thử đơn vị bao phủ các đường dẫn auth, payment subscription quan trọng
- Skeleton loading, error boundary, và cải thiện khả năng truy cập trên web frontend
- Tích hợp theo dõi lỗi Sentry cho cả API và ứng dụng web
### Added
- Listing duplicate detection service to prevent redundant property submissions
- Subscription quota enforcement with per-plan feature limits and usage metering
- Google and Zalo OAuth backend strategies for social login
- 58 unit tests covering critical auth, payment, and subscription paths
- Loading skeletons, error boundaries, and accessibility improvements on web frontend
- Sentry error tracking integration for both API and web apps
### Đã sửa
- Tăng cường cấu hình triển khai Docker production cho tất cả các dịch vụ
### Fixed
- Hardened production Docker deployment configuration for all services
---
## [1.0.0] - 2026-03-01
### Đã thêm
### Added
#### Xác Thực & Bảo Mật
- Đăng ký và đăng nhập người dùng bằng số điện thoại và mật khẩu
- JWT access token (hết hạn sau 15 phút) với xoay vòng refresh token (hết hạn sau 7 ngày)
- Phát hiện xoay vòng dựa trên token family để ngăn chặn tấn công replay
- Hỗ trợ đăng nhập mạng xã hội OAuth (Google, Zalo)
- Quy trình xác minh KYC (Know Your Customer) (NONE -> PENDING -> VERIFIED/REJECTED)
- Kiểm soát truy cập theo vai trò với decorator `@Roles()` (USER, AGENT, ADMIN)
- Rate limiting: mặc định 60 req/phút, 10 req/phút cho auth, 20 req/phút cho payment callback
- `ThrottlerBehindProxyGuard` để theo dõi IP nhận biết X-Forwarded-For
- Tiêu đề bảo mật Helmet, cấu hình CORS
- Kiểm tra đầu vào (class-validator) và làm sạch nội dung (sanitize-html)
- Bảo vệ CSRF với mẫu double-submit cookie
- Che giấu PII trong structured log (Pino)
- Băm mật khẩu Bcrypt
#### Authentication & Security
- User registration and login with phone number and password
- JWT access tokens (15-minute expiry) with refresh token rotation (7-day expiry)
- Token family-based rotation detection to prevent replay attacks
- OAuth social login support (Google, Zalo)
- KYC (Know Your Customer) verification workflow (NONE -> PENDING -> VERIFIED/REJECTED)
- Role-based access control with `@Roles()` decorator (USER, AGENT, ADMIN)
- Rate limiting: 60 req/min default, 10 req/min auth, 20 req/min payment callbacks
- `ThrottlerBehindProxyGuard` for X-Forwarded-For-aware IP tracking
- Helmet security headers, CORS configuration
- Input validation (class-validator) and content sanitization (sanitize-html)
- CSRF protection with double-submit cookie pattern
- PII masking in structured logs (Pino)
- Bcrypt password hashing
#### Danh Sách Bất Động Sản
- CRUD đầy đủ cho danh sách bất động sản với máy trạng thái (DRAFT -> PENDING_REVIEW -> ACTIVE -> RESERVED -> SOLD/RENTED)
- Hỗ trợ tải lên media (S3/MinIO) với kiểm tra tệp
- Chấm điểm kiểm duyệt hỗ trợ bởi AI qua Claude API
- Hàng đợi kiểm duyệt admin với phê duyệt/từ chối hàng loạt
- Tạo danh sách bị giới hạn bởi quota gắn với gói subscription
#### Property Listings
- Full CRUD for property listings with status state machine (DRAFT -> PENDING_REVIEW -> ACTIVE -> RESERVED -> SOLD/RENTED)
- Media upload support (S3/MinIO) with file validation
- AI-assisted moderation scoring via Claude API
- Admin moderation queue with bulk approve/reject
- Quota-gated listing creation tied to subscription plans
#### Tìm Kiếm & Khám Phá
- Tìm kiếm bất động sản toàn văn bản qua Typesense với hỗ trợ tiếng Việt
- Tìm kiếm địa lý không gian bằng PostGIS (truy vấn lat/long + bán kính)
- Lọc nhiều mặt theo giá, loại bất động sản, số phòng ngủ, quận huyện
- Cập nhật chỉ mục tìm kiếm theo sự kiện (listing approved/updated/sold -> re-index)
- Xóa cache theo tin tố cho kết quả tìm kiếm
#### Search & Discovery
- Full-text property search via Typesense with Vietnamese language support
- Geo-spatial search using PostGIS (lat/long + radius queries)
- Faceted filtering by price, property type, bedrooms, district
- Event-driven search index updates (listing approved/updated/sold -> re-index)
- Prefix-based cache invalidation for search results
#### Thanh Toán
- Xử lý thanh toán với tích hợp các nhà cung cấp VNPay, MoMo ZaloPay
- Xử lý webhook callback idempotent với xác minh chữ ký
- Hỗ trợ hoàn tiền
- Chuyển đổi trạng thái nguyên tử (PENDING -> COMPLETED/FAILED)
- Phát sự kiện khi hoàn thành/thất bại thanh toán cho xử lý downstream
#### Payments
- Payment processing with VNPay, MoMo, and ZaloPay provider integration
- Idempotent webhook callback handling with signature verification
- Payment refund support
- Atomic status transitions (PENDING -> COMPLETED/FAILED)
- Event emission on payment completion/failure for downstream processing
#### Subscription & Thanh Toán Định Kỳ
- Các gói subscription với cờ tính năng phân tầng (cột JSON)
- Đo lường mức sử dụng và kiểm tra quota (được hỗ trợ bởi Redis)
- Nâng cấp và hủy gói
- Theo dõi lịch sử thanh toán
- Theo dõi mức sử dụng theo sự kiện (`listing.created` -> đo lường mức sử dụng)
#### Subscriptions & Billing
- Subscription plans with tiered feature flags (JSON columns)
- Usage metering and quota enforcement (Redis-backed)
- Plan upgrades and cancellations
- Billing history tracking
- Event-driven usage tracking (`listing.created` -> meter usage)
#### Bảng Điều Khiển Admin
- Dashboard với thống kê toàn hệ thống
- Quản lý người dùng (liệt kê, xem, cấm/bỏ cấm)
- Hàng đợi phê duyệt KYC với hành động phê duyệt/từ chối
- Hàng đợi kiểm duyệt danh sách với kiểm duyệt hàng loạt
- Thống kê doanh thu và analytics
- Điều chỉnh subscription cho người dùng cá nhân
#### Admin Panel
- Dashboard with system-wide statistics
- User management (list, view, ban/unban)
- KYC approval queue with approve/reject actions
- Listing moderation queue with bulk moderation
- Revenue statistics and analytics
- Subscription adjustment for individual users
#### Analytics & Dữ Liệu Thị Trường
- Báo cáo thị trường theo quận huyện với tổng hợp không gian PostGIS
- Phân tích xu hướng giá theo loại bất động sản và quận huyện
- Dữ liệu heatmap quận huyện (tổng hợp địa lý)
- Theo dõi và cập nhật chỉ số thị trường
- Phân phối báo cáo dựa trên cache
#### Analytics & Market Data
- District-level market reports with PostGIS spatial aggregation
- Price trend analysis by property type and district
- District heatmap data (geo aggregates)
- Market index tracking and updates
- Cache-based report delivery
#### Thông Báo
- Gửi thông báo đa kênh: EMAIL, SMS, PUSH (FCM), IN_APP
- 8 listener theo sự kiện: email chào mừng, phê duyệt KYC, phê duyệt/từ chối danh sách, xác nhận/thất bại thanh toán, hết hạn subscription, vượt quota
- Mẫu email Handlebars với bản địa hóa tiếng Việt
- Tùy chọn thông báo người dùng (từ chối nhận theo kênh/loại)
#### Notifications
- Multi-channel notification delivery: EMAIL, SMS, PUSH (FCM), IN_APP
- 8 event-driven listeners: welcome email, KYC approval, listing approval/rejection, payment confirmation/failure, subscription expiry, quota exceeded
- Handlebars email templates with Vietnamese localization
- User notification preferences (opt-out per channel/type)
#### Đánh Giá
- Đánh giá bất động sản và agent với xếp hạng 1-5 sao
- CRUD đánh giá với tính đa hình đối tượng (agent hoặc bất động sản)
- Tính toán xếp hạng trung bình theo đối tượng
#### Reviews
- Property and agent reviews with 1-5 star ratings
- Review CRUD with target polymorphism (agent or property)
- Average rating calculation per target
#### Máy Chủ MCP (Model Context Protocol)
#### MCP (Model Context Protocol) Servers
- Property Search Server: `search_properties`, `compare_properties`, `get_property_details`
- Market Analytics Server: `get_market_report`, `analyze_trends`, `get_price_indices`
- Valuation Server: `estimate_valuation`, `extract_features`, `compare_valuations` (XGBoost qua FastAPI)
- HTTP transport controller với `McpRegistryService`
- Valuation Server: `estimate_valuation`, `extract_features`, `compare_valuations` (XGBoost via FastAPI)
- HTTP transport controller with `McpRegistryService`
#### Dịch Vụ AI
- Microservice FastAPI với mô hình định giá bất động sản XGBoost
- Kiểm duyệt nội dung mô tả danh sách được hỗ trợ bởi Claude API
- Tiền xử lý NLP tiếng Việt với Underthesea
#### AI Services
- FastAPI microservice with XGBoost property valuation model
- Claude API-powered content moderation for listing descriptions
- Vietnamese NLP preprocessing with Underthesea
#### Hạ Tầng
- PostgreSQL 16 với extension PostGIS (22 model, chỉ mục không gian)
- Tầng Redis caching cho search, analytics, quota và dữ liệu phiên
- Công cụ tìm kiếm Typesense với hỗ trợ tiếng Việt
- Endpoint Prometheus metrics với histogram thời gian yêu cầu HTTP và bộ đếm tỷ lệ lỗi
- Dashboard Grafana tự động cấu hình từ thư mục `monitoring/`
- Ghi log JSON có cấu trúc Pino với correlation ID
- Prisma ORM với hệ thống migration và dữ liệu seed (quận huyện/phường Thành phố Hồ Chí Minh, bất động sản mẫu, các gói subscription)
#### Infrastructure
- PostgreSQL 16 with PostGIS extension (22 models, spatial indexes)
- Redis caching layer for search, analytics, quota, and session data
- Typesense search engine with Vietnamese language support
- Prometheus metrics endpoint with HTTP request duration histograms and error rate counters
- Grafana dashboards auto-provisioned from `monitoring/` directory
- Pino structured JSON logging with correlation IDs
- Prisma ORM with migration system and seed data (Ho Chi Minh City districts/wards, sample properties, subscription plans)
#### Frontend (Next.js 15)
- App Router với Tailwind CSS và quản lý trạng thái Zustand
- Trang tìm kiếm bất động sản với tích hợp bản đồ Mapbox GL
- Trang chi tiết danh sách với thư viện media
- Dashboard agent với quản lý KYC, subscription và thanh toán
- Trực quan hóa heatmap quận huyện
- Giao diện định giá bất động sản với tích hợp AVM
- Nút chuyển đổi dark mode
- Skeleton loading và error boundary
- Văn bản giao diện tiếng Việt xuyên suốt (loại bất động sản, quận huyện, tiền tệ theo VND)
- App Router with Tailwind CSS and Zustand state management
- Property search page with Mapbox GL map integration
- Listing detail pages with media gallery
- Agent dashboard with KYC, subscription, and payment management
- District heatmap visualization
- Property valuation UI with AVM integration
- Dark mode toggle
- Loading skeletons and error boundaries
- Vietnamese UI text throughout (property types, districts, currency in VND)
#### Trải Nghiệm Nhà Phát Triển
- Monorepo với pnpm workspaces Turborepo
- ESLint với các quy tắc sắp xếp import
- Định dạng code Prettier
- Git hook Husky
- Kiểm thử E2E với Playwright (14 tệp kiểm thử web)
- CI pipeline GitHub Actions (lint -> typecheck -> test -> build)
#### Developer Experience
- Monorepo with pnpm workspaces and Turborepo
- ESLint with import ordering rules
- Prettier code formatting
- Husky git hooks
- E2E tests with Playwright (14 web test files)
- GitHub Actions CI pipeline (lint -> typecheck -> test -> build)
### Bảo Mật
- Lưu trữ token dựa trên cookie httpOnly với tăng cường CSRF
- Khóa idempotency trên các luồng thanh toán với kiểm tra số tin
- Kiểm tra magic byte cho tệp tải lên media
- Ghi log kiểm tra admin
- Kiểm tra audience/issuer JWT
- Kiểm tra biến môi trường production
- `.env.example` được làm sạch (không rò rỉ bí mật)
- Hook tắt dịch vụ nhẹ nhàng để kết thúc tiến trình sạch
### Security
- httpOnly cookie-based token storage with CSRF hardening
- Idempotency keys on payment flows with amount validation
- Magic byte file validation for media uploads
- Admin audit logging
- JWT audience/issuer validation
- Production environment variable validation
- Sanitized `.env.example` (no leaked secrets)
- Graceful shutdown hooks for clean process termination
[Unreleased]: https://github.com/goodgo/platform-ai/compare/v1.4.0...HEAD
[1.4.0]: https://github.com/goodgo/platform-ai/compare/v1.3.0...v1.4.0

View File

@@ -1,270 +1,34 @@
# Hướng Dẫn Đóng Góp
# Contributing Guide
## Kỷ Luật Commit & Push (Bắt Buộc)
## Error Handling Convention
> Để tránh conflict khi nhiều agent/engineer làm việc song song, toàn bộ team PHẢI tuân thủ các quy định sau. Nguồn: [GOO-91](/GOO/issues/GOO-91) (chỉ thị từ CEO qua [GOO-88](/GOO/issues/GOO-88)).
### Overview
1. **Commit ngay khi hoàn thành task** — mỗi task = một commit (hoặc một chuỗi commit nhỏ liên quan). Không gom nhiều task không liên quan vào một commit lớn.
2. **Pull/rebase trước khi push** — luôn chạy `git pull --rebase origin <branch>` trước `git push` để giảm merge conflict.
3. **Push ngay sau commit** — không giữ commit local quá 1 ngày làm việc. Commit không push = rủi ro mất việc + conflict tăng.
4. **Conventional Commits** — bắt buộc (`feat:`, `fix:`, `chore:`, `refactor:`, `docs:`, `test:`, `style:`, `perf:`). Xem [Quy Ước Commit](#quy-ước-commit) bên dưới.
5. **KHÔNG push trực tiếp lên `main` / `master`** — luôn dùng feature branch + Pull Request. Branch chính được bảo vệ bằng GitHub branch protection rules.
6. **PR phải pass CI** (`lint``typecheck``test``build`) trước khi merge. PR đỏ CI không được merge dù đã approve.
7. **Squash-merge khi merge PR** — giữ history trên `main` sạch, mỗi PR = một commit logic.
8. **Xóa feature branch sau khi merge** — tránh branch sprawl. GitHub có auto-delete branch sau merge; bật nó trong repo settings.
All application-layer error handling uses **domain exceptions** from `@modules/shared/domain/domain-exception`. Never import exception classes from `@nestjs/common` in handlers — use the project's own domain exceptions instead.
### Flow nhanh cho mỗi task
```bash
# 1. Tạo/chuyển sang feature branch (KHÔNG commit trực tiếp vào main)
git checkout -b feature/goo-xx-short-description
# 2. Làm việc, khi hoàn thành task:
git add <files>
git commit -m "feat(scope): mô tả ngắn"
# 3. Đồng bộ & push
git pull --rebase origin main # hoặc develop
git push -u origin feature/goo-xx-short-description
# 4. Mở PR, chờ CI xanh + review, squash-merge, xóa branch
```
---
## Quy Trình Git & Branching
### Nhánh Chính
| Nhánh | Mục đích | Protected |
|-------|---------|-----------|
| `main` / `master` | Production branch — stable releases | ✅ Yes |
| `develop` | Development branch — integration point | ✅ Yes |
| `feature/*` | Feature branches — phát triển tính năng mới | ❌ No |
| `fix/*` | Bug fix branches | ❌ No |
| `docs/*` | Documentation updates | ❌ No |
| `refactor/*` | Code refactoring, cleanup | ❌ No |
### Quy Trình Tạo Feature Branch
```bash
# 1. Cập nhật branch chính
git checkout develop
git pull origin develop
# 2. Tạo feature branch
git checkout -b feature/awesome-feature
# Naming convention:
# feature/user-authentication
# fix/csrf-middleware-double-middleware
# docs/api-documentation
# refactor/remove-dead-code
```
### Pull Request Workflow
```bash
# 1. Commit changes
git add .
git commit -m "feat(auth): implement phone OTP login"
# 2. Push to origin
git push origin feature/awesome-feature
# 3. Open PR on GitHub
# - Title: Short summary (max 70 chars)
# - Description: Why, what changed, how to test
# - Link related issues: Fixes #GOO-7
# - Request reviewers: team lead, domain expert
# 4. Address review feedback
git add .
git commit -m "refactor(auth): address PR feedback"
git push
# 5. Merge when approved
# - Squash commits if many small fixes
# - Delete branch after merge
```
### Protected Branch Rules
`main``develop` branches yêu cầu:
- ✅ All CI checks pass (lint, typecheck, test, build)
- ✅ 1 approval từ code owner
- ✅ Dismiss stale PR approvals
- ✅ Branches must be up to date before merge
- ❌ Force push không được phép
---
## Quy Ước Commit
Theo chuẩn **[Conventional Commits](https://www.conventionalcommits.org/)**:
```
<type>(<scope>): <subject>
<body>
<footer>
```
### Loại Commit (Type)
| Type | Mục đích | Ví dụ |
|------|---------|-------|
| **feat** | Tính năng mới | `feat(auth): add phone OTP login` |
| **fix** | Bug fix | `fix(csrf): remove double middleware` |
| **docs** | Tài liệu | `docs(readme): update setup instructions` |
| **style** | Code style (không thay đổi logic) | `style(payment): format code` |
| **refactor** | Refactor code | `refactor(search): extract filter logic` |
| **perf** | Performance improvement | `perf(search): add Typesense caching` |
| **test** | Test changes | `test(auth): add OTP verification tests` |
| **chore** | Dependencies, build, etc | `chore(deps): upgrade TypeScript 5.2` |
### Scope
Scope là module/area bị ảnh hưởng:
```
feat(auth): ... # Auth module
feat(payments): ... # Payments module
feat(api): ... # General API
feat(web): ... # Frontend
feat(deps): ... # Dependencies
```
### Subject (Tiêu đề)
- ✅ Bắt đầu bằng **verb** (not past tense): "add", "fix", "remove"
- ✅ Viết **lowercase** (trừ proper nouns)
-**Không kết thúc** bằng dấu chấm
- ✅ Tối đa **50 ký tự**
- ❌ Không dùng "update", "change" — cụ thể hơn
### Body (Chi tiết)
Tùy chọn, giải thích **why****how**:
```
feat(payments): implement MoMo IPN webhook
Fix MoMo IPN callback to use correct backend URL instead of frontend URL.
- Extract ipnUrl from redirectUrl in MoMo service
- Validate HMAC signature before processing payment
- Add retry logic for idempotent callbacks
Fixes #GOO-6
```
### Footer (Tham chiếu)
Tham chiếu issue:
```
Fixes #GOO-7
Closes #GOO-8
Related to #GOO-5
```
### Ví dụ Hoàn Chỉnh
```
feat(auth): implement phone OTP login flow
Add phone OTP as primary login method for Vietnamese users.
Simplifies sign-up process vs password login.
- Add OTP request endpoint: POST /auth/otp/request
- Add OTP verify endpoint: POST /auth/otp/verify
- Store OTP in Redis with 5min expiry
- Prevent brute force: max 3 attempts per phone per hour
- Add unit tests for OTP generation and verification
Fixes #GOO-11
Co-Authored-By: Paperclip <noreply@paperclip.ing>
```
### Kiểm Tra Commit Message
Dùng `husky` pre-commit hook:
```bash
# Tự động chạy khi git commit
# Kiểm tra:
# - Format conventional commits
# - No secrets (API keys, passwords)
# - Lint, typecheck
# Nếu hook thất bại, fix và commit lại
```
---
## Pull Request Template
```markdown
## Summary
Một dòng mô tả PR (tương tự commit subject).
## Changes
- Điểm thay đổi 1
- Điểm thay đổi 2
- Điểm thay đổi 3
## Testing
- [ ] Unit tests written
- [ ] E2E tests written (if applicable)
- [ ] Manual testing: describe steps
- [ ] No regressions found
## Screenshots / Logs (if applicable)
Paste images or log outputs.
## Related Issues
Fixes #GOO-7
Related to #GOO-5
## Notes for Reviewers
- Pay attention to X because Y
- Known limitations: Z
```
---
## Quy Ước Xử Lý Lỗi
### Tổng Quan
Toàn bộ xử lý lỗi ở tầng ứng dụng sử dụng **domain exceptions** từ `@modules/shared/domain/domain-exception`. Không bao giờ import các lớp exception từ `@nestjs/common` trong handlers — hãy dùng domain exceptions của dự án.
`GlobalExceptionFilter` bắt tất cả các exception và chuẩn hóa chúng thành một JSON response nhất quán với error codes có cấu trúc.
The `GlobalExceptionFilter` catches all exceptions and normalizes them into a consistent JSON response with structured error codes.
### Domain Exceptions
| Exception | HTTP Status | Khi nào dùng |
| Exception | HTTP Status | When to use |
|---|---|---|
| `NotFoundException(entity, id?)` | 404 | Không tìm thấy entity trong database |
| `ValidationException(message, details?)` | 400 | Dữ liệu đầu vào không hợp lệ, vi phạm business rule, lỗi tạo value object |
| `ConflictException(message)` | 409 | Tài nguyên bị trùng lặp, vi phạm idempotency |
| `UnauthorizedException(message?)` | 401 | Thông tin xác thực hoặc token không hợp lệ/đã hết hạn |
| `ForbiddenException(message?)` | 403 | Đã xác thực nhưng không được phép thực hiện hành động |
| `NotFoundException(entity, id?)` | 404 | Entity not found in database |
| `ValidationException(message, details?)` | 400 | Invalid input, business rule violation, value object creation failure |
| `ConflictException(message)` | 409 | Duplicate resource, idempotency violation |
| `UnauthorizedException(message?)` | 401 | Invalid/expired credentials or tokens |
| `ForbiddenException(message?)` | 403 | Authenticated but not authorized for the action |
Import từ:
Import from:
```typescript
import { NotFoundException, ValidationException } from '@modules/shared/domain/domain-exception';
```
### Các Mẫu Theo Từng Tầng
### Patterns by Layer
#### Command/Query Handlers
Handlers ném domain exceptions trực tiếp. Không cần bọc try-catch — `GlobalExceptionFilter` xử lý các exception chưa được bắt.
Handlers throw domain exceptions directly. No try-catch wrapping needed — the `GlobalExceptionFilter` handles uncaught exceptions.
```typescript
// Good: domain exception with entity context
@@ -286,11 +50,11 @@ if (subscription.status === 'CANCELLED') {
#### Controllers
Controllers là các tầng ủy quyền mỏng — chúng dispatch tới command/query bus và trả về kết quả. Không cần xử lý lỗi ở tầng controller.
Controllers are thin delegation layers — they dispatch to the command/query bus and return the result. No error handling needed at the controller level.
#### Domain Services / Value Objects
Sử dụng mẫu `Result<T, E>` từ `@modules/shared/domain/result`:
Use the `Result<T, E>` pattern from `@modules/shared/domain/result`:
```typescript
static create(value: string): Result<Phone, string> {
@@ -299,9 +63,9 @@ static create(value: string): Result<Phone, string> {
}
```
Handlers sử dụng `Result` bằng cách kiểm tra `.isErr` và ném `ValidationException`.
Handlers consume `Result` by checking `.isErr` and throwing a `ValidationException`.
### Những Điều KHÔNG Nên Làm
### What NOT to Do
```typescript
// Bad: NestJS built-in exceptions (missing errorCode in response)
@@ -321,89 +85,8 @@ try {
// Handlers should unwrap Result and throw on error
```
### Kiểu Trả Về Của Repository
### Repository Return Types
Tất cả các phương thức đọc của repository phải trả về DTOs được định kiểu rõ ràng — không bao giờ dùng `Promise<any>` hoặc `PaginatedResult<any>`. Định nghĩa read DTOs ở tầng domain cùng với interface của repository.
Xem `listing-read.dto.ts` để tham khảo ví dụ chuẩn.
---
## Code Review Checklist
Khi review PR, kiểm tra:
### Functionality
- [ ] Changes meet acceptance criteria
- [ ] No breaking changes (or documented)
- [ ] Error handling is robust
- [ ] Edge cases covered
### Code Quality
- [ ] Code follows conventions (style, naming, patterns)
- [ ] No `console.log`, `TODO` without issue reference
- [ ] No dead code, unused imports
- [ ] Functions have clear responsibility
### Testing
- [ ] Unit tests cover happy path + error cases
- [ ] E2E tests for critical flows (if applicable)
- [ ] Coverage maintained / improved (API ≥60%, Web ≥50%)
- [ ] No flaky tests
### Documentation
- [ ] Code comments explain "why", not "what"
- [ ] Updated docs if API/process changed
- [ ] Commit messages follow conventions
### Security
- [ ] No hardcoded secrets (API keys, passwords)
- [ ] Input validation in place
- [ ] Auth checks in place
- [ ] No SQL injection (use Prisma, not raw SQL)
### Performance
- [ ] No N+1 queries
- [ ] Caching applied where appropriate
- [ ] No blocking operations in event loop
---
## Release Process
### Versioning
Tuân theo **Semantic Versioning**: `MAJOR.MINOR.PATCH`
- **MAJOR:** Breaking changes (require migration)
- **MINOR:** New features (backward compatible)
- **PATCH:** Bug fixes
### Creating a Release
```bash
# 1. Update CHANGELOG.md with changes
# 2. Bump version in package.json (root)
# 3. Create git tag
git tag -a v1.5.0 -m "Release 1.5.0: Add phone OTP login"
git push origin v1.5.0
# 4. GitHub Actions automatically:
# - Builds Docker image
# - Pushes to GitHub Container Registry
# - Creates GitHub Release
# - Deploys to staging (auto)
# - Waits for manual approval for production
```
---
## Questions?
- 📖 Read `/docs/architecture.md` for system design
- 🏗️ Read `/docs/QUICK_REFERENCE.md` for patterns
- 💬 Ask on Slack `#dev` channel
- 🐛 File an issue: https://github.com/hongochai10/goodgo-bds-platform-ai/issues
**Happy coding! 🚀**
All repository read methods must return explicitly typed DTOs — never `Promise<any>` or `PaginatedResult<any>`. Define read DTOs in the domain layer alongside the repository interface.
See `listing-read.dto.ts` for the canonical example.

106
README.md
View File

@@ -1,23 +1,23 @@
# GoodGo Platform AI
Nền tảng bất động sản thông minh của Việt Nam — tìm kiếm nhà đất, định giá bằng AI và quản lý giao dịch toàn trình.
Vietnam's intelligent real estate platform — property search, AI-powered valuation, and end-to-end transaction management.
## Công Nghệ Sử Dụng
## Tech Stack
| Tầng | Công nghệ |
| Layer | Technology |
|-------|-----------|
| **Backend** | NestJS 11, TypeScript, Prisma ORM, CQRS |
| **Frontend** | Next.js 15, React 18, Tailwind CSS, Zustand |
| **Cơ sở dữ liệu** | PostgreSQL 16 + PostGIS 3.4 |
| **Tìm kiếm** | Typesense 27 |
| **Database** | PostgreSQL 16 + PostGIS 3.4 |
| **Search** | Typesense 27 |
| **Cache/Queue** | Redis 7 |
| **AI/ML** | FastAPI, XGBoost, Claude API, Underthesea |
| **MCP** | Model Context Protocol servers (tìm kiếm nhà đất, định giá, phân tích) |
| **Lưu trữ** | MinIO (tương thích S3) |
| **Giám sát** | Prometheus, Grafana, Loki + Promtail |
| **Thanh toán** | VNPay, MoMo, ZaloPay |
| **MCP** | Model Context Protocol servers (property search, valuation, analytics) |
| **Storage** | MinIO (S3-compatible) |
| **Monitoring** | Prometheus, Grafana, Loki + Promtail |
| **Payments** | VNPay, MoMo, ZaloPay |
## Tổng Quan Kiến Trúc
## Architecture Overview
```
┌─────────────┐ ┌──────────────┐ ┌──────────────────┐
@@ -47,7 +47,7 @@ Nền tảng bất động sản thông minh của Việt Nam — tìm kiếm nh
└────────────────┘
```
## Cấu Trúc Monorepo
## Monorepo Structure
```
goodgo-platform-ai/
@@ -64,15 +64,15 @@ goodgo-platform-ai/
└── docs/ # Developer documentation
```
## Khởi Động Nhanh
## Quick Start
### Yêu Cầu Tiên Quyết
### Prerequisites
- **Docker Engine 24+** & Docker Compose v2
- **Node.js 22 LTS**
- **pnpm 10.27+** (`corepack enable && corepack prepare pnpm@latest --activate`)
### Cài Đặt
### Setup
```bash
# 1. Clone the repository
@@ -103,26 +103,26 @@ pnpm db:seed
pnpm dev
```
API sẽ khả dụng tại `http://localhost:3001/api/v1` và ứng dụng web tại `http://localhost:3000`.
The API will be available at `http://localhost:3001/api/v1` and the web app at `http://localhost:3000`.
> **Swagger UI**: Mở `http://localhost:3001/api/v1/docs` để xem tài liệu API tương tác.
> **Swagger UI**: Open `http://localhost:3001/api/v1/docs` for interactive API documentation.
### Các Dịch Vụ Hạ Tầng
### Infrastructure Services
| Dịch vụ | Cổng | Bảng điều khiển |
| Service | Port(s) | Dashboard |
|---------|---------|-----------|
| PostgreSQL + PostGIS | 5432 | — |
| Redis | 6379 | — |
| Typesense | 8108 | `http://localhost:8108/health` |
| MinIO | 9000 / 9001 | `http://localhost:9001` (console) |
| AI Services (FastAPI) | 8000 | `http://localhost:8000/health` |
| Loki (tổng hợp log) | 3100 | `http://localhost:3100/ready` |
| Loki (log aggregation) | 3100 | `http://localhost:3100/ready` |
| Prometheus | 9090 | `http://localhost:9090` |
| Grafana | 3002 | `http://localhost:3002` |
## Phát Triển
## Development
### Các Lệnh Thông Dụng
### Common Commands
```bash
pnpm dev # Start all apps (API + Web)
@@ -133,7 +133,7 @@ pnpm format # Format with Prettier
pnpm test # Run unit/integration tests
```
### Cơ Sở Dữ Liệu
### Database
```bash
pnpm db:generate # Regenerate Prisma client
@@ -144,7 +144,7 @@ pnpm db:studio # Open Prisma Studio (visual editor)
pnpm db:reset # Reset database (destructive)
```
### Kiểm Thử E2E
### E2E Testing
```bash
pnpm test:e2e # Run all E2E tests
@@ -153,41 +153,41 @@ pnpm test:e2e:web # Web UI tests only
pnpm test:e2e:report # Open HTML test report
```
## Các Module API
## API Modules
Tất cả route API đều có tiền tố `/api/v1/`. Mỗi module tuân theo Domain-Driven Design với các tầng `presentation/`, `application/`, `domain/` `infrastructure/`.
All API routes are prefixed with `/api/v1/`. Each module follows Domain-Driven Design with `presentation/`, `application/`, `domain/`, and `infrastructure/` layers.
| Module | Mô tả |
| Module | Description |
|--------|-------------|
| **auth** | Đăng ký, đăng nhập, xoay vòng JWT + refresh token, OAuth (Google/Zalo), KYC, xuất/xoá dữ liệu người dùng |
| **listings** | CRUD tin đăng nhà đất, quy trình trạng thái, quản lý tệp phương tiện |
| **search** | Tìm kiếm toàn văn bản Typesense kết hợp bộ lọc địa lý, lưu tìm kiếm |
| **payments** | Tích hợp VNPay, MoMo, ZaloPay kèm xác thực callback |
| **subscriptions** | Quản lý gói dịch vụ, theo dõi mức sử dụng, kiểm soát hạn mức |
| **notifications** | Lịch sử thông báo qua email và trong ứng dụng cùng tuỳ chọn cá nhân |
| **admin** | Kiểm duyệt tin đăng, quản lý người dùng, nhật ký kiểm tra |
| **analytics** | Báo cáo thị trường, chỉ số giá, tích hợp AVM |
| **agents** | Hồ sơ và xác minh môi giới bất động sản |
| **inquiries** | Quản lý yêu cầu tư vấn nhà đất |
| **leads** | Theo dõi và chuyển đổi khách hàng tiềm năng |
| **reviews** | Đánh giá và xếp hạng bất động sản |
| **health** | Kiểm tra liveness readiness |
| **mcp** | Cầu nối MCP server (tìm kiếm nhà đất, định giá, phân tích) |
| **metrics** | Thu thập metrics Prometheus và web vitals |
| **shared** | Mối quan tâm chung: guards, pipes, filters, dịch vụ Prisma/Redis |
| **auth** | Registration, login, JWT + refresh token rotation, OAuth (Google/Zalo), KYC, user data export/deletion |
| **listings** | Property listing CRUD, status workflow, media management |
| **search** | Typesense full-text search with geo-spatial filters, saved searches |
| **payments** | VNPay, MoMo, ZaloPay integration with callback verification |
| **subscriptions** | Plan management, usage tracking, quota enforcement |
| **notifications** | Email and in-app notification history & preferences |
| **admin** | Listing moderation, user management, audit logs |
| **analytics** | Market reports, price indices, AVM integration |
| **agents** | Real estate agent profiles and verification |
| **inquiries** | Property inquiry management |
| **leads** | Lead tracking and conversion |
| **reviews** | Property reviews and ratings |
| **health** | Liveness and readiness health checks |
| **mcp** | MCP server bridge (property search, valuation, analytics) |
| **metrics** | Prometheus metrics and web vitals collection |
| **shared** | Cross-cutting concerns: guards, pipes, filters, Prisma/Redis services |
## Tài Liệu
## Documentation
| Tài liệu | Mô tả |
| Document | Description |
|----------|-------------|
| [Môi trường phát triển](docs/dev-environment.md) | Cài đặt Docker và các dịch vụ cục bộ |
| [Kiến trúc](docs/architecture.md) | Thiết kế hệ thống, luồng dữ liệu, cấu trúc module |
| [API Endpoints](docs/api-endpoints.md) | Tài liệu tham khảo REST API endpoint |
| [Mã lỗi API](docs/api-error-codes.md) | Định dạng phản hồi lỗi và toàn bộ mã lỗi |
| [Triển khai](docs/deployment.md) | Hướng dẫn triển khai môi trường sản xuất |
| [Sao lưu & Khôi phục](docs/backup-restore.md) | Quy trình sao lưu và khôi phục sau sự cố |
| [Đóng góp](CONTRIBUTING.md) | Quy ước xử lý lỗi và các mẫu lập trình |
| [Development Environment](docs/dev-environment.md) | Docker setup and local services |
| [Architecture](docs/architecture.md) | System design, data flow, module structure |
| [API Endpoints](docs/api-endpoints.md) | REST API endpoint reference |
| [API Error Codes](docs/api-error-codes.md) | Error response format and all error codes |
| [Deployment](docs/deployment.md) | Production deployment guide |
| [Backup & Restore](docs/backup-restore.md) | Backup procedures and disaster recovery |
| [Contributing](CONTRIBUTING.md) | Error handling conventions and coding patterns |
## Giấy Phép
## License
Độc quyền — Bảo lưu mọi quyền.
Proprietary — All rights reserved.

View File

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

View File

@@ -1,50 +0,0 @@
# 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.

View File

@@ -1,77 +0,0 @@
{
"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" }]
}
]
}

View File

@@ -16,12 +16,7 @@
"@anthropic-ai/sdk": "^0.89.0",
"@aws-sdk/client-s3": "^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/contracts-events": "workspace:*",
"@nest-lab/throttler-storage-redis": "^1.2.0",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.4",
@@ -42,7 +37,6 @@
"@prisma/client": "^7.7.0",
"@sentry/nestjs": "^10.47.0",
"@sentry/profiling-node": "^10.47.0",
"@socket.io/redis-adapter": "^8.3.0",
"@willsoto/nestjs-prometheus": "^6.1.0",
"bcrypt": "^6.0.0",
"bullmq": "^5.74.1",
@@ -53,7 +47,6 @@
"handlebars": "^4.7.9",
"helmet": "^8.1.0",
"ioredis": "^5.4.0",
"jsonwebtoken": "^9.0.3",
"nodemailer": "^8.0.5",
"otplib": "^13.4.0",
"passport": "^0.7.0",
@@ -80,7 +73,6 @@
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.5.2",
"@types/nodemailer": "^8.0.0",
"@types/passport-google-oauth20": "^2.0.17",

View File

@@ -1,4 +1,3 @@
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
import { BullModule } from '@nestjs/bullmq';
import { type MiddlewareConsumer, Module, type NestModule, RequestMethod } from '@nestjs/common';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
@@ -10,7 +9,6 @@ import { AdminModule } from '@modules/admin';
import { AgentsModule } from '@modules/agents';
import { AnalyticsModule } from '@modules/analytics';
import { AuthModule } from '@modules/auth';
import { FavoritesModule } from '@modules/favorites';
import { HealthModule } from '@modules/health';
import { IndustrialModule } from '@modules/industrial';
import { InquiriesModule } from '@modules/inquiries';
@@ -20,11 +18,7 @@ import { McpIntegrationModule } from '@modules/mcp';
import { MessagingModule } from '@modules/messaging';
import { HttpMetricsInterceptor, MetricsModule } from '@modules/metrics';
import { NotificationsModule } from '@modules/notifications';
import { OsmSyncModule } from '@modules/osm-sync/osm-sync.module';
import { PaymentsModule } from '@modules/payments';
import { PoiModule } from '@modules/poi/poi.module';
import { ProjectsModule } from '@modules/projects';
import { QueuesModule } from '@modules/queues/queues.module';
import { ReportsModule } from '@modules/reports';
import { ReviewsModule } from '@modules/reviews';
import { SearchModule } from '@modules/search';
@@ -32,7 +26,6 @@ import { SharedModule } from '@modules/shared';
import { ThrottlerBehindProxyGuard } from '@modules/shared/infrastructure/guards/throttler-behind-proxy.guard';
import { CsrfMiddleware } from '@modules/shared/infrastructure/middleware/csrf.middleware';
import { SanitizeInputMiddleware } from '@modules/shared/infrastructure/middleware/sanitize-input.middleware';
import { getRedisConnection } from '@modules/shared/infrastructure/redis-connection.config';
import { SubscriptionsModule } from '@modules/subscriptions';
import { TransferModule } from '@modules/transfer';
import { AppController } from './app.controller';
@@ -41,11 +34,11 @@ import { AppController } from './app.controller';
imports: [
SentryModule.forRoot(),
BullModule.forRoot({
// RFC-004 Phase 3 — use the queue-specific Redis connection so ops can
// split cache traffic from queue traffic without a code change. Falls
// back to REDIS_HOST/PORT/PASSWORD when the queue-specific vars are
// unset. See shared/infrastructure/redis-connection.config.ts.
connection: getRedisConnection('queue'),
connection: {
host: process.env['REDIS_HOST'] ?? 'localhost',
port: Number(process.env['REDIS_PORT'] ?? 6379),
password: process.env['REDIS_PASSWORD'] ?? undefined,
},
}),
CqrsModule.forRoot(),
ScheduleModule.forRoot(),
@@ -57,32 +50,22 @@ import { AppController } from './app.controller';
LeadsModule,
ListingsModule,
ReviewsModule,
FavoritesModule,
SearchModule,
NotificationsModule,
OsmSyncModule,
PaymentsModule,
PoiModule,
SubscriptionsModule,
AdminModule,
AnalyticsModule,
MetricsModule,
MetricsModule.withQueueMetrics(),
McpIntegrationModule,
MessagingModule,
ReportsModule,
ProjectsModule,
IndustrialModule,
TransferModule,
// ── Bull Board UI (RFC-004 Phase 3 WS3b) ──
QueuesModule,
// ── Rate Limiting ──
// Default: 60 requests per 60 seconds per IP
// Override per-route with @Throttle() decorator
// Storage: Redis-backed sliding window so limits are shared across
// every API instance (required for TEC-2930 feature-listing throttling).
ThrottlerModule.forRoot({
throttlers: [
{
@@ -101,21 +84,6 @@ import { AppController } from './app.controller';
limit: process.env['NODE_ENV'] === 'test' || process.env['NODE_ENV'] === 'development' ? 10_000 : 20,
},
],
storage: new ThrottlerStorageRedisService({
host: process.env['REDIS_HOST'] ?? 'localhost',
port: Number(process.env['REDIS_PORT'] ?? 6379),
password: process.env['REDIS_PASSWORD'] ?? undefined,
// Single retry per command + bounded reconnect backoff so a
// transient Redis blip cannot stall the request path. Behaviour
// matches RedisService for consistency.
maxRetriesPerRequest: 1,
enableReadyCheck: false,
lazyConnect: true,
retryStrategy(times: number): number {
return Math.min(times * 1000, 5000);
},
keyPrefix: 'throttler:',
}),
}),
],
controllers: [AppController],
@@ -150,10 +118,6 @@ export class AppModule implements NestModule {
.exclude(
{ path: 'health', method: RequestMethod.GET },
{ path: 'health/(.*)', method: RequestMethod.GET },
{ 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: 'api/v1/admin/queues', method: RequestMethod.ALL },
{ path: 'api/v1/admin/queues/(.*)', method: RequestMethod.ALL },
)
.forRoutes('*');
}

View File

@@ -8,7 +8,7 @@ const isTest = process.env['NODE_ENV'] === 'test';
const integrations: any[] = [];
if (!isTest) {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { nodeProfilingIntegration } = require('@sentry/profiling-node') as typeof import('@sentry/profiling-node');
integrations.push(nodeProfilingIntegration());
} catch {

View File

@@ -8,10 +8,11 @@ import './instrument';
import { RequestMethod, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import cookieParser from 'cookie-parser';
import helmet from 'helmet';
import { LoggerService, RedisIoAdapter, validateEnv } from '@modules/shared';
import { LoggerService, validateEnv } from '@modules/shared';
import { AppModule } from './app.module';
async function bootstrap() {
@@ -59,11 +60,7 @@ async function bootstrap() {
});
// ── WebSocket Adapter (Socket.IO) ──
// Redis pub/sub fan-out for multi-instance broadcasts; falls back to the
// in-memory IoAdapter when Redis is unreachable (single-node / local dev).
const wsAdapter = new RedisIoAdapter(app);
await wsAdapter.connectToRedis();
app.useWebSocketAdapter(wsAdapter);
app.useWebSocketAdapter(new IoAdapter(app));
// ── Security Headers (Helmet) ──
app.use(

View File

@@ -1,43 +1,30 @@
import { forwardRef, Module } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { AuthModule } from '@modules/auth';
import { ListingsModule } from '@modules/listings';
import { AI_CONFIG_PROVIDER } from '@modules/shared';
import { SubscriptionsModule } from '@modules/subscriptions';
import { AdjustSubscriptionHandler } from './application/commands/adjust-subscription/adjust-subscription.handler';
import { ApproveKycHandler } from './application/commands/approve-kyc/approve-kyc.handler';
import { ApproveListingHandler } from './application/commands/approve-listing/approve-listing.handler';
import { BanUserHandler } from './application/commands/ban-user/ban-user.handler';
import { BulkModerateListingsHandler } from './application/commands/bulk-moderate-listings/bulk-moderate-listings.handler';
import { ProvisionDeveloperHandler } from './application/commands/provision-developer/provision-developer.handler';
import { ProvisionParkOperatorHandler } from './application/commands/provision-park-operator/provision-park-operator.handler';
import { RejectKycHandler } from './application/commands/reject-kyc/reject-kyc.handler';
import { RejectListingHandler } from './application/commands/reject-listing/reject-listing.handler';
import { UpdateAiSettingsHandler } from './application/commands/update-ai-settings/update-ai-settings.handler';
import { UpdateUserStatusHandler } from './application/commands/update-user-status/update-user-status.handler';
import { AdminAuditListener } from './application/listeners/admin-audit.listener';
import { ModerationAuditListener } from './application/listeners/moderation-audit.listener';
import { UserBannedListener } from './application/listeners/user-banned.listener';
import { UserDeactivatedListener } from './application/listeners/user-deactivated.listener';
import { GetAiSettingsHandler } from './application/queries/get-ai-settings/get-ai-settings.handler';
import { GetAuditLogsHandler } from './application/queries/get-audit-logs/get-audit-logs.handler';
import { GetDashboardStatsHandler } from './application/queries/get-dashboard-stats/get-dashboard-stats.handler';
import { GetFlaggedListingsHandler } from './application/queries/get-flagged-listings/get-flagged-listings.handler';
import { GetKycQueueHandler } from './application/queries/get-kyc-queue/get-kyc-queue.handler';
import { GetModerationAuditLogsHandler } from './application/queries/get-moderation-audit-logs/get-moderation-audit-logs.handler';
import { GetModerationQueueHandler } from './application/queries/get-moderation-queue/get-moderation-queue.handler';
import { GetRevenueStatsHandler } from './application/queries/get-revenue-stats/get-revenue-stats.handler';
import { GetUserDetailHandler } from './application/queries/get-user-detail/get-user-detail.handler';
import { GetUsersHandler } from './application/queries/get-users/get-users.handler';
import { SystemSettingsService } from './application/services/system-settings.service';
import { ADMIN_QUERY_REPOSITORY } from './domain/repositories/admin-query.repository';
import { AUDIT_LOG_REPOSITORY } from './domain/repositories/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 { PrismaAuditLogRepository } from './infrastructure/repositories/prisma-audit-log.repository';
import { PrismaModerationAuditLogRepository } from './infrastructure/repositories/prisma-moderation-audit-log.repository';
import { AdminModerationAuditController } from './presentation/controllers/admin-moderation-audit.controller';
import { AdminModerationController } from './presentation/controllers/admin-moderation.controller';
import { AdminController } from './presentation/controllers/admin.controller';
@@ -50,43 +37,25 @@ const CommandHandlers = [
ApproveKycHandler,
RejectKycHandler,
BulkModerateListingsHandler,
UpdateAiSettingsHandler,
ProvisionDeveloperHandler,
ProvisionParkOperatorHandler,
];
const QueryHandlers = [
GetModerationQueueHandler,
GetFlaggedListingsHandler,
GetDashboardStatsHandler,
GetRevenueStatsHandler,
GetUsersHandler,
GetUserDetailHandler,
GetKycQueueHandler,
GetAuditLogsHandler,
GetModerationAuditLogsHandler,
GetAiSettingsHandler,
];
@Module({
imports: [CqrsModule, AuthModule, forwardRef(() => ListingsModule), SubscriptionsModule],
controllers: [
AdminController,
AdminModerationController,
AdminModerationAuditController,
],
imports: [CqrsModule, AuthModule, ListingsModule, SubscriptionsModule],
controllers: [AdminController, AdminModerationController],
providers: [
// Repositories
{ provide: ADMIN_QUERY_REPOSITORY, useClass: PrismaAdminQueryRepository },
{ provide: AUDIT_LOG_REPOSITORY, useClass: PrismaAuditLogRepository },
{
provide: MODERATION_AUDIT_LOG_REPOSITORY,
useClass: PrismaModerationAuditLogRepository,
},
// Services
SystemSettingsService,
{ provide: AI_CONFIG_PROVIDER, useClass: SystemSettingsAiConfigProvider },
// CQRS
...CommandHandlers,
@@ -96,8 +65,6 @@ const QueryHandlers = [
UserBannedListener,
UserDeactivatedListener,
AdminAuditListener,
ModerationAuditListener,
],
exports: [SystemSettingsService, AI_CONFIG_PROVIDER],
})
export class AdminModule {}

View File

@@ -1,94 +0,0 @@
import { GetModerationAuditLogsHandler } from '../queries/get-moderation-audit-logs/get-moderation-audit-logs.handler';
import { GetModerationAuditLogsQuery } from '../queries/get-moderation-audit-logs/get-moderation-audit-logs.query';
describe('GetModerationAuditLogsHandler', () => {
let handler: GetModerationAuditLogsHandler;
let mockRepo: { findAll: ReturnType<typeof vi.fn> };
let mockLogger: {
log: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
};
const mockResult = {
data: [
{
id: 'mod-1',
targetType: 'listing',
targetId: 'listing-1',
action: 'approve',
moderatorId: 'admin-1',
reason: null,
metadata: null,
createdAt: new Date('2026-04-10T10:00:00Z'),
},
],
total: 1,
page: 1,
limit: 20,
totalPages: 1,
};
beforeEach(() => {
mockRepo = { findAll: vi.fn().mockResolvedValue(mockResult) };
mockLogger = { log: vi.fn(), error: vi.fn() };
handler = new GetModerationAuditLogsHandler(
mockRepo as any,
mockLogger as any,
);
});
it('returns paginated moderation audit logs with default filters', async () => {
const result = await handler.execute(new GetModerationAuditLogsQuery());
expect(result).toEqual(mockResult);
expect(mockRepo.findAll).toHaveBeenCalledWith({
page: 1,
limit: 20,
targetType: undefined,
targetId: undefined,
action: undefined,
moderatorId: undefined,
startDate: undefined,
endDate: undefined,
});
});
it('passes filters through to the repository', async () => {
const start = new Date('2026-04-01');
const end = new Date('2026-04-30');
await handler.execute(
new GetModerationAuditLogsQuery(
2,
50,
'listing',
'listing-1',
'reject',
'mod-9',
start,
end,
),
);
expect(mockRepo.findAll).toHaveBeenCalledWith({
page: 2,
limit: 50,
targetType: 'listing',
targetId: 'listing-1',
action: 'reject',
moderatorId: 'mod-9',
startDate: start,
endDate: end,
});
});
it('wraps unexpected errors as InternalServerErrorException', async () => {
mockRepo.findAll.mockRejectedValue(new Error('boom'));
await expect(
handler.execute(new GetModerationAuditLogsQuery()),
).rejects.toThrow('Lỗi khi lấy nhật ký kiểm duyệt');
expect(mockLogger.error).toHaveBeenCalled();
});
});

View File

@@ -1,130 +0,0 @@
import { type ListingApprovedEvent } from '../../domain/events/listing-approved.event';
import { type ListingRejectedEvent } from '../../domain/events/listing-rejected.event';
import { ModerationAuditListener } from '../listeners/moderation-audit.listener';
describe('ModerationAuditListener', () => {
let listener: ModerationAuditListener;
let mockRepo: { create: ReturnType<typeof vi.fn> };
let mockLogger: {
log: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockRepo = {
create: vi.fn().mockResolvedValue({
id: 'mod-audit-1',
targetType: 'listing',
targetId: 'listing-1',
action: 'approve',
moderatorId: 'admin-1',
reason: null,
metadata: null,
createdAt: new Date(),
}),
};
mockLogger = { log: vi.fn(), error: vi.fn() };
listener = new ModerationAuditListener(
mockRepo as any,
mockLogger as any,
);
});
it('writes a moderation audit row when a listing is approved with notes', async () => {
const event: ListingApprovedEvent = {
aggregateId: 'listing-1',
adminId: 'admin-1',
moderationNotes: 'OK',
eventName: 'listing.approved_by_admin',
occurredAt: new Date(),
};
await listener.onListingApproved(event);
expect(mockRepo.create).toHaveBeenCalledWith({
targetType: 'listing',
targetId: 'listing-1',
action: 'approve',
moderatorId: 'admin-1',
reason: 'OK',
metadata: { moderationNotes: 'OK' },
});
});
it('writes a moderation audit row when a listing is approved without notes', async () => {
const event: ListingApprovedEvent = {
aggregateId: 'listing-1',
adminId: 'admin-1',
eventName: 'listing.approved_by_admin',
occurredAt: new Date(),
};
await listener.onListingApproved(event);
expect(mockRepo.create).toHaveBeenCalledWith({
targetType: 'listing',
targetId: 'listing-1',
action: 'approve',
moderatorId: 'admin-1',
reason: undefined,
metadata: undefined,
});
});
it('writes a moderation audit row when a listing is rejected', async () => {
const event: ListingRejectedEvent = {
aggregateId: 'listing-2',
adminId: 'admin-2',
reason: 'Vi phạm nội dung',
eventName: 'listing.rejected_by_admin',
occurredAt: new Date(),
};
await listener.onListingRejected(event);
expect(mockRepo.create).toHaveBeenCalledWith({
targetType: 'listing',
targetId: 'listing-2',
action: 'reject',
moderatorId: 'admin-2',
reason: 'Vi phạm nội dung',
metadata: { reason: 'Vi phạm nội dung' },
});
});
it('does not throw when repository write fails', async () => {
mockRepo.create.mockRejectedValue(new Error('DB down'));
const event: ListingApprovedEvent = {
aggregateId: 'listing-3',
adminId: 'admin-1',
eventName: 'listing.approved_by_admin',
occurredAt: new Date(),
};
await expect(listener.onListingApproved(event)).resolves.toBeUndefined();
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('Failed to write moderation audit log'),
expect.any(String),
'ModerationAuditListener',
);
});
it('logs success after writing audit entry', async () => {
const event: ListingRejectedEvent = {
aggregateId: 'listing-9',
adminId: 'admin-9',
reason: 'spam',
eventName: 'listing.rejected_by_admin',
occurredAt: new Date(),
};
await listener.onListingRejected(event);
expect(mockLogger.log).toHaveBeenCalledWith(
'Moderation audit: reject by admin-9 on listing:listing-9',
'ModerationAuditListener',
);
});
});

View File

@@ -1,7 +1,7 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { type PlanTier } from '@prisma/client';
import { DomainException, NotFoundException, ValidationException, PrismaService, LoggerService } from '@modules/shared';
import { DomainException, NotFoundException, ValidationException, type PrismaService, type LoggerService } from '@modules/shared';
import { SUBSCRIPTION_REPOSITORY, type ISubscriptionRepository } from '@modules/subscriptions';
import { SubscriptionAdjustedEvent } from '../../../domain/events/subscription-adjusted.event';
import { AdjustSubscriptionCommand } from './adjust-subscription.command';

View File

@@ -1,7 +1,7 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { USER_REPOSITORY, type IUserRepository } from '@modules/auth';
import { DomainException, NotFoundException, ValidationException, LoggerService } from '@modules/shared';
import { DomainException, NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
import { KycApprovedEvent } from '../../../domain/events/kyc-approved.event';
import { ApproveKycCommand } from './approve-kyc.command';

View File

@@ -1,7 +1,7 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { LISTING_REPOSITORY, type IListingRepository } from '@modules/listings';
import { DomainException, NotFoundException, ValidationException, LoggerService } from '@modules/shared';
import { DomainException, NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
import { ListingApprovedEvent } from '../../../domain/events/listing-approved.event';
import { ApproveListingCommand } from './approve-listing.command';

View File

@@ -1,7 +1,7 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { USER_REPOSITORY, type IUserRepository } from '@modules/auth';
import { DomainException, NotFoundException, ValidationException, LoggerService } from '@modules/shared';
import { DomainException, NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
import { UserBannedEvent } from '../../../domain/events/user-banned.event';
import { UserUnbannedEvent } from '../../../domain/events/user-unbanned.event';
import { BanUserCommand } from './ban-user.command';

View File

@@ -1,7 +1,7 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { LISTING_REPOSITORY, type IListingRepository } from '@modules/listings';
import { DomainException, ValidationException, LoggerService } from '@modules/shared';
import { DomainException, ValidationException, type LoggerService } from '@modules/shared';
import { ListingApprovedEvent } from '../../../domain/events/listing-approved.event';
import { ListingRejectedEvent } from '../../../domain/events/listing-rejected.event';
import { BulkModerateListingsCommand } from './bulk-moderate-listings.command';

View File

@@ -14,5 +14,3 @@ export { RejectKycCommand } from './reject-kyc/reject-kyc.command';
export { RejectKycHandler } from './reject-kyc/reject-kyc.handler';
export { BulkModerateListingsCommand } from './bulk-moderate-listings/bulk-moderate-listings.command';
export { BulkModerateListingsHandler } from './bulk-moderate-listings/bulk-moderate-listings.handler';
export { UpdateAiSettingsCommand } from './update-ai-settings/update-ai-settings.command';
export { UpdateAiSettingsHandler } from './update-ai-settings/update-ai-settings.handler';

View File

@@ -1,18 +0,0 @@
/**
* Admin command: create a DEVELOPER (CĐT) user account and optionally link
* existing `ProjectDevelopment` records to that user as owner.
*
* Flow: admin picks phone/email/fullName/password, optionally an array of
* projectIds. Handler creates the user, then batch-assigns those projects'
* `ownerId`. Projects already owned by someone else are rejected.
*/
export class ProvisionDeveloperCommand {
constructor(
public readonly phone: string,
public readonly password: string,
public readonly fullName: string,
public readonly email: string | null,
/** Project ids to assign as owned by the new developer (optional). */
public readonly projectIds: string[],
) {}
}

View File

@@ -1,103 +0,0 @@
import { ConflictException, Inject } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
import { PrismaService, ValidationException } from '@modules/shared';
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 { HashedPassword } from '../../../../auth/domain/value-objects/hashed-password.vo';
import { Phone } from '../../../../auth/domain/value-objects/phone.vo';
import { ProvisionDeveloperCommand } from './provision-developer.command';
export interface ProvisionDeveloperResult {
userId: string;
phone: string;
email: string | null;
fullName: string;
linkedProjectIds: string[];
}
@CommandHandler(ProvisionDeveloperCommand)
export class ProvisionDeveloperHandler
implements ICommandHandler<ProvisionDeveloperCommand, ProvisionDeveloperResult>
{
constructor(
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
private readonly prisma: PrismaService,
) {}
async execute(cmd: ProvisionDeveloperCommand): Promise<ProvisionDeveloperResult> {
// Validate + hash auth fields.
const phoneResult = Phone.create(cmd.phone);
if (phoneResult.isErr) throw new ValidationException(phoneResult.unwrapErr());
const phone = phoneResult.unwrap();
let email: Email | undefined;
if (cmd.email) {
const emailResult = Email.create(cmd.email);
if (emailResult.isErr) throw new ValidationException(emailResult.unwrapErr());
email = emailResult.unwrap();
}
const passwordResult = await HashedPassword.fromPlain(cmd.password);
if (passwordResult.isErr) throw new ValidationException(passwordResult.unwrapErr());
const passwordHash = passwordResult.unwrap();
// Uniqueness.
if (await this.userRepo.findByPhone(phone.value)) {
throw new ConflictException('Số điện thoại đã được đăng ký');
}
if (email && (await this.userRepo.findByEmail(email.value))) {
throw new ConflictException('Email đã được đăng ký');
}
// Pre-validate project ownership before creating the user — avoids
// orphaning a user if any target project is already owned by someone else.
if (cmd.projectIds.length > 0) {
const rows = await this.prisma.projectDevelopment.findMany({
where: { id: { in: cmd.projectIds } },
select: { id: true, ownerId: true, name: true },
});
const byId = new Map(rows.map((r) => [r.id, r]));
const missing = cmd.projectIds.filter((id) => !byId.has(id));
if (missing.length > 0) {
throw new ValidationException(
`Không tìm thấy dự án: ${missing.join(', ')}`,
);
}
const occupied = rows.filter((r) => r.ownerId && r.ownerId !== null);
if (occupied.length > 0) {
throw new ConflictException(
`Các dự án đã có CĐT khác quản lý: ${occupied.map((r) => r.name).join(', ')}`,
);
}
}
// Create the user (role=DEVELOPER).
const user = UserEntity.createNew(
createId(),
phone,
cmd.fullName,
passwordHash,
email,
'DEVELOPER',
);
await this.userRepo.save(user);
// Link projects.
if (cmd.projectIds.length > 0) {
await this.prisma.projectDevelopment.updateMany({
where: { id: { in: cmd.projectIds } },
data: { ownerId: user.id },
});
}
return {
userId: user.id,
phone: user.phone.value,
email: user.email?.value ?? null,
fullName: user.fullName,
linkedProjectIds: cmd.projectIds,
};
}
}

View File

@@ -1,13 +0,0 @@
/**
* Admin command: create a PARK_OPERATOR user account and optionally link
* existing `IndustrialPark` records to that user as owner.
*/
export class ProvisionParkOperatorCommand {
constructor(
public readonly phone: string,
public readonly password: string,
public readonly fullName: string,
public readonly email: string | null,
public readonly parkIds: string[],
) {}
}

View File

@@ -1,95 +0,0 @@
import { ConflictException, Inject } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
import { PrismaService, ValidationException } from '@modules/shared';
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 { HashedPassword } from '../../../../auth/domain/value-objects/hashed-password.vo';
import { Phone } from '../../../../auth/domain/value-objects/phone.vo';
import { ProvisionParkOperatorCommand } from './provision-park-operator.command';
export interface ProvisionParkOperatorResult {
userId: string;
phone: string;
email: string | null;
fullName: string;
linkedParkIds: string[];
}
@CommandHandler(ProvisionParkOperatorCommand)
export class ProvisionParkOperatorHandler
implements ICommandHandler<ProvisionParkOperatorCommand, ProvisionParkOperatorResult>
{
constructor(
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
private readonly prisma: PrismaService,
) {}
async execute(cmd: ProvisionParkOperatorCommand): Promise<ProvisionParkOperatorResult> {
const phoneResult = Phone.create(cmd.phone);
if (phoneResult.isErr) throw new ValidationException(phoneResult.unwrapErr());
const phone = phoneResult.unwrap();
let email: Email | undefined;
if (cmd.email) {
const emailResult = Email.create(cmd.email);
if (emailResult.isErr) throw new ValidationException(emailResult.unwrapErr());
email = emailResult.unwrap();
}
const passwordResult = await HashedPassword.fromPlain(cmd.password);
if (passwordResult.isErr) throw new ValidationException(passwordResult.unwrapErr());
const passwordHash = passwordResult.unwrap();
if (await this.userRepo.findByPhone(phone.value)) {
throw new ConflictException('Số điện thoại đã được đăng ký');
}
if (email && (await this.userRepo.findByEmail(email.value))) {
throw new ConflictException('Email đã được đăng ký');
}
if (cmd.parkIds.length > 0) {
const rows = await this.prisma.industrialPark.findMany({
where: { id: { in: cmd.parkIds } },
select: { id: true, ownerId: true, name: true },
});
const byId = new Map(rows.map((r) => [r.id, r]));
const missing = cmd.parkIds.filter((id) => !byId.has(id));
if (missing.length > 0) {
throw new ValidationException(`Không tìm thấy KCN: ${missing.join(', ')}`);
}
const occupied = rows.filter((r) => r.ownerId && r.ownerId !== null);
if (occupied.length > 0) {
throw new ConflictException(
`Các KCN đã có đơn vị vận hành khác: ${occupied.map((r) => r.name).join(', ')}`,
);
}
}
const user = UserEntity.createNew(
createId(),
phone,
cmd.fullName,
passwordHash,
email,
'PARK_OPERATOR',
);
await this.userRepo.save(user);
if (cmd.parkIds.length > 0) {
await this.prisma.industrialPark.updateMany({
where: { id: { in: cmd.parkIds } },
data: { ownerId: user.id },
});
}
return {
userId: user.id,
phone: user.phone.value,
email: user.email?.value ?? null,
fullName: user.fullName,
linkedParkIds: cmd.parkIds,
};
}
}

View File

@@ -1,7 +1,7 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { USER_REPOSITORY, type IUserRepository } from '@modules/auth';
import { DomainException, NotFoundException, ValidationException, LoggerService } from '@modules/shared';
import { DomainException, NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
import { KycRejectedEvent } from '../../../domain/events/kyc-rejected.event';
import { RejectKycCommand } from './reject-kyc.command';

View File

@@ -1,7 +1,7 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { LISTING_REPOSITORY, type IListingRepository } from '@modules/listings';
import { DomainException, NotFoundException, ValidationException, LoggerService } from '@modules/shared';
import { DomainException, NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
import { ListingRejectedEvent } from '../../../domain/events/listing-rejected.event';
import { RejectListingCommand } from './reject-listing.command';

View File

@@ -1,8 +0,0 @@
export class UpdateAiSettingsCommand {
constructor(
public readonly adminId: string,
public readonly apiUrl?: string,
public readonly apiKey?: string,
public readonly model?: string,
) {}
}

View File

@@ -1,43 +0,0 @@
import { InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, LoggerService } from '@modules/shared';
import { type AiSettingsDto } from '../../queries/get-ai-settings/get-ai-settings.handler';
import { SystemSettingsService } from '../../services/system-settings.service';
import { UpdateAiSettingsCommand } from './update-ai-settings.command';
@CommandHandler(UpdateAiSettingsCommand)
export class UpdateAiSettingsHandler
implements ICommandHandler<UpdateAiSettingsCommand>
{
constructor(
private readonly systemSettings: SystemSettingsService,
private readonly logger: LoggerService,
) {}
async execute(command: UpdateAiSettingsCommand): Promise<AiSettingsDto> {
try {
const updated = await this.systemSettings.updateAiSettings({
apiUrl: command.apiUrl,
apiKey: command.apiKey,
model: command.model,
updatedBy: command.adminId,
});
return {
apiUrl: updated.apiUrl,
apiKeyMasked: SystemSettingsService.maskApiKey(updated.apiKey),
model: updated.model,
hasApiKey: Boolean(updated.apiKey),
updatedAt: updated.updatedAt ? updated.updatedAt.toISOString() : null,
};
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to update AI settings: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
'UpdateAiSettingsHandler',
);
throw new InternalServerErrorException('Lỗi khi lưu cài đặt AI');
}
}
}

View File

@@ -1,7 +1,7 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { USER_REPOSITORY, type IUserRepository } from '@modules/auth';
import { DomainException, NotFoundException, ValidationException, LoggerService } from '@modules/shared';
import { DomainException, NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
import { UserBannedEvent } from '../../../domain/events/user-banned.event';
import { UserUnbannedEvent } from '../../../domain/events/user-unbanned.event';
import { UpdateUserStatusCommand } from './update-user-status.command';

View File

@@ -1,13 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import {
type EmailChangeRequestedEvent,
type EmailChangedEvent,
type PhoneChangeRequestedEvent,
type PhoneChangedEvent,
} from '@modules/auth';
import { type ListingOwnershipTransferredEvent } from '@modules/listings';
import { LoggerService } from '@modules/shared';
import { type LoggerService } from '@modules/shared';
import { type KycApprovedEvent } from '../../domain/events/kyc-approved.event';
import { type KycRejectedEvent } from '../../domain/events/kyc-rejected.event';
import { type ListingApprovedEvent } from '../../domain/events/listing-approved.event';
@@ -75,73 +68,6 @@ export class AdminAuditListener {
});
}
// ── Listing ownership transfer (TEC-2928) ────────────────────────────
@OnEvent('listing.ownership_transferred', { async: true })
async onListingOwnershipTransferred(
event: ListingOwnershipTransferredEvent,
): Promise<void> {
await this.log(
'LISTING_OWNERSHIP_TRANSFER',
event.byUserId,
event.aggregateId,
'LISTING',
{
fromAgentId: event.fromAgentId,
toAgentId: event.toAgentId,
actorRole: event.byRole,
},
);
}
// ── Sensitive user profile field changes (OTP-gated) ─────────────────
@OnEvent('user.email_change_requested', { async: true })
async onEmailChangeRequested(event: EmailChangeRequestedEvent): Promise<void> {
// Actor is the user themselves — they initiated the change.
// Do NOT include the OTP code in the audit metadata.
await this.log(
'EMAIL_CHANGE_REQUESTED',
event.aggregateId,
event.aggregateId,
'USER',
{ newEmail: event.newEmail },
);
}
@OnEvent('user.phone_change_requested', { async: true })
async onPhoneChangeRequested(event: PhoneChangeRequestedEvent): Promise<void> {
await this.log(
'PHONE_CHANGE_REQUESTED',
event.aggregateId,
event.aggregateId,
'USER',
{ newPhone: event.newPhone },
);
}
@OnEvent('user.email_changed', { async: true })
async onEmailChanged(event: EmailChangedEvent): Promise<void> {
await this.log(
'EMAIL_CHANGED',
event.aggregateId,
event.aggregateId,
'USER',
{ oldEmail: event.oldEmail, newEmail: event.newEmail },
);
}
@OnEvent('user.phone_changed', { async: true })
async onPhoneChanged(event: PhoneChangedEvent): Promise<void> {
await this.log(
'PHONE_CHANGED',
event.aggregateId,
event.aggregateId,
'USER',
{ oldPhone: event.oldPhone, newPhone: event.newPhone },
);
}
private async log(
action: string,
actorId: string,

View File

@@ -1,70 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { LoggerService } from '@modules/shared';
import { type ListingApprovedEvent } from '../../domain/events/listing-approved.event';
import { type ListingRejectedEvent } from '../../domain/events/listing-rejected.event';
import {
MODERATION_AUDIT_LOG_REPOSITORY,
type CreateModerationAuditLogInput,
type IModerationAuditLogRepository,
} from '../../domain/repositories/moderation-audit-log.repository';
/**
* Write-side hook that records every moderation action into
* `ModerationAuditLog`. It listens to domain events published by the existing
* moderation command handlers (approve/reject/bulk) so the public API of those
* handlers stays unchanged, per TEC-2926.
*
* Failures are swallowed (logged only) so an audit write never breaks the
* primary moderation flow.
*/
@Injectable()
export class ModerationAuditListener {
constructor(
@Inject(MODERATION_AUDIT_LOG_REPOSITORY)
private readonly moderationAuditRepo: IModerationAuditLogRepository,
private readonly logger: LoggerService,
) {}
@OnEvent('listing.approved_by_admin', { async: true })
async onListingApproved(event: ListingApprovedEvent): Promise<void> {
await this.write({
targetType: 'listing',
targetId: event.aggregateId,
action: 'approve',
moderatorId: event.adminId,
reason: event.moderationNotes,
metadata: event.moderationNotes
? { moderationNotes: event.moderationNotes }
: undefined,
});
}
@OnEvent('listing.rejected_by_admin', { async: true })
async onListingRejected(event: ListingRejectedEvent): Promise<void> {
await this.write({
targetType: 'listing',
targetId: event.aggregateId,
action: 'reject',
moderatorId: event.adminId,
reason: event.reason,
metadata: { reason: event.reason },
});
}
private async write(input: CreateModerationAuditLogInput): Promise<void> {
try {
await this.moderationAuditRepo.create(input);
this.logger.log(
`Moderation audit: ${input.action} by ${input.moderatorId} on ${input.targetType}:${input.targetId}`,
'ModerationAuditListener',
);
} catch (error) {
this.logger.error(
`Failed to write moderation audit log: ${input.action} by ${input.moderatorId} on ${input.targetType}:${input.targetId}`,
error instanceof Error ? error.stack : String(error),
'ModerationAuditListener',
);
}
}
}

View File

@@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { SendNotificationCommand } from '@modules/notifications';
import { LoggerService, PrismaService } from '@modules/shared';
import { type LoggerService, type PrismaService } from '@modules/shared';
import { type UserBannedEvent } from '../../domain/events/user-banned.event';
@Injectable()

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { type UserDeactivatedEvent } from '@modules/auth';
import { LoggerService, PrismaService } from '@modules/shared';
import { type LoggerService, type PrismaService } from '@modules/shared';
@Injectable()
export class UserDeactivatedListener {

View File

@@ -1,42 +0,0 @@
import { InternalServerErrorException } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { DomainException, LoggerService } from '@modules/shared';
import { SystemSettingsService } from '../../services/system-settings.service';
import { GetAiSettingsQuery } from './get-ai-settings.query';
export interface AiSettingsDto {
apiUrl: string;
apiKeyMasked: string | null;
model: string;
hasApiKey: boolean;
updatedAt: string | null;
}
@QueryHandler(GetAiSettingsQuery)
export class GetAiSettingsHandler implements IQueryHandler<GetAiSettingsQuery> {
constructor(
private readonly systemSettings: SystemSettingsService,
private readonly logger: LoggerService,
) {}
async execute(_query: GetAiSettingsQuery): Promise<AiSettingsDto> {
try {
const current = await this.systemSettings.getAiSettings();
return {
apiUrl: current.apiUrl,
apiKeyMasked: SystemSettingsService.maskApiKey(current.apiKey),
model: current.model,
hasApiKey: Boolean(current.apiKey),
updatedAt: current.updatedAt ? current.updatedAt.toISOString() : null,
};
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to get AI settings: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
'GetAiSettingsHandler',
);
throw new InternalServerErrorException('Lỗi khi đọc cài đặt AI');
}
}
}

View File

@@ -1,3 +0,0 @@
export class GetAiSettingsQuery {
constructor() {}
}

View File

@@ -1,6 +1,6 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { DomainException, LoggerService } from '@modules/shared';
import { DomainException, type LoggerService } from '@modules/shared';
import {
AUDIT_LOG_REPOSITORY,
type IAuditLogRepository,

View File

@@ -1,6 +1,6 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { DomainException, LoggerService } from '@modules/shared';
import { DomainException, type LoggerService } from '@modules/shared';
import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type DashboardStats } from '../../../domain/repositories/admin-query.repository';
import { GetDashboardStatsQuery } from './get-dashboard-stats.query';

View File

@@ -1,109 +0,0 @@
import { InternalServerErrorException } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { DomainException, LoggerService, PrismaService } from '@modules/shared';
import { GetFlaggedListingsQuery } from './get-flagged-listings.query';
export interface FlaggedListingItem {
listingId: string;
propertyTitle: string;
sellerName: string;
status: string;
totalReports: number;
reasons: string[];
latestReportAt: string;
}
export interface FlaggedListingsResult {
items: FlaggedListingItem[];
total: number;
page: number;
limit: number;
}
@QueryHandler(GetFlaggedListingsQuery)
export class GetFlaggedListingsHandler implements IQueryHandler<GetFlaggedListingsQuery> {
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async execute(query: GetFlaggedListingsQuery): Promise<FlaggedListingsResult> {
try {
const { page, limit } = query;
const skip = (page - 1) * limit;
// Get listings that have pending flags, grouped by listing
const flaggedListings = await this.prisma.listingFlag.groupBy({
by: ['listingId'],
where: { status: 'PENDING' },
_count: { id: true },
_max: { createdAt: true },
orderBy: { _count: { id: 'desc' } },
skip,
take: limit,
});
const totalGroups = await this.prisma.listingFlag.groupBy({
by: ['listingId'],
where: { status: 'PENDING' },
});
const total = totalGroups.length;
if (flaggedListings.length === 0) {
return { items: [], total: 0, page, limit };
}
const listingIds = flaggedListings.map((f) => f.listingId);
// Fetch listing details
const listings = await this.prisma.listing.findMany({
where: { id: { in: listingIds } },
select: {
id: true,
status: true,
property: { select: { title: true } },
seller: { select: { fullName: true } },
},
});
const listingMap = new Map(listings.map((l) => [l.id, l]));
// Fetch distinct reasons per listing
const reasonFlags = await this.prisma.listingFlag.findMany({
where: { listingId: { in: listingIds }, status: 'PENDING' },
select: { listingId: true, reason: true },
distinct: ['listingId', 'reason'],
});
const reasonMap = new Map<string, string[]>();
for (const rf of reasonFlags) {
const arr = reasonMap.get(rf.listingId) ?? [];
arr.push(rf.reason);
reasonMap.set(rf.listingId, arr);
}
const items: FlaggedListingItem[] = flaggedListings.map((group) => {
const listing = listingMap.get(group.listingId);
return {
listingId: group.listingId,
propertyTitle: listing?.property?.title ?? 'Unknown',
sellerName: listing?.seller?.fullName ?? 'Unknown',
status: listing?.status ?? 'UNKNOWN',
totalReports: group._count.id,
reasons: reasonMap.get(group.listingId) ?? [],
latestReportAt: group._max.createdAt?.toISOString() ?? '',
};
});
return { items, total, page, limit };
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to get flagged listings: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
'GetFlaggedListingsHandler',
);
throw new InternalServerErrorException('Lỗi khi lấy danh sách tin bị báo cáo');
}
}
}

View File

@@ -1,6 +0,0 @@
export class GetFlaggedListingsQuery {
constructor(
public readonly page: number = 1,
public readonly limit: number = 20,
) {}
}

View File

@@ -1,6 +1,6 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { DomainException, LoggerService } from '@modules/shared';
import { DomainException, type LoggerService } from '@modules/shared';
import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type KycQueueResult } from '../../../domain/repositories/admin-query.repository';
import { GetKycQueueQuery } from './get-kyc-queue.query';

View File

@@ -1,47 +0,0 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { DomainException, LoggerService } from '@modules/shared';
import {
MODERATION_AUDIT_LOG_REPOSITORY,
type IModerationAuditLogRepository,
type ModerationAuditLogListResult,
} from '../../../domain/repositories/moderation-audit-log.repository';
import { GetModerationAuditLogsQuery } from './get-moderation-audit-logs.query';
@QueryHandler(GetModerationAuditLogsQuery)
export class GetModerationAuditLogsHandler
implements IQueryHandler<GetModerationAuditLogsQuery>
{
constructor(
@Inject(MODERATION_AUDIT_LOG_REPOSITORY)
private readonly repo: IModerationAuditLogRepository,
private readonly logger: LoggerService,
) {}
async execute(
query: GetModerationAuditLogsQuery,
): Promise<ModerationAuditLogListResult> {
try {
return await this.repo.findAll({
page: query.page,
limit: query.limit,
targetType: query.targetType,
targetId: query.targetId,
action: query.action,
moderatorId: query.moderatorId,
startDate: query.startDate,
endDate: query.endDate,
});
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to get moderation audit logs: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
'GetModerationAuditLogsHandler',
);
throw new InternalServerErrorException(
'Lỗi khi lấy nhật ký kiểm duyệt',
);
}
}
}

View File

@@ -1,12 +0,0 @@
export class GetModerationAuditLogsQuery {
constructor(
public readonly page: number = 1,
public readonly limit: number = 20,
public readonly targetType?: string,
public readonly targetId?: string,
public readonly action?: string,
public readonly moderatorId?: string,
public readonly startDate?: Date,
public readonly endDate?: Date,
) {}
}

View File

@@ -1,6 +1,6 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { DomainException, LoggerService } from '@modules/shared';
import { DomainException, type LoggerService } from '@modules/shared';
import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type ModerationQueueResult } from '../../../domain/repositories/admin-query.repository';
import { GetModerationQueueQuery } from './get-moderation-queue.query';

View File

@@ -1,6 +1,6 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { DomainException, LoggerService } from '@modules/shared';
import { DomainException, type LoggerService } from '@modules/shared';
import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type RevenueStatsItem } from '../../../domain/repositories/admin-query.repository';
import { GetRevenueStatsQuery } from './get-revenue-stats.query';

View File

@@ -1,6 +1,6 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { DomainException, NotFoundException, LoggerService } from '@modules/shared';
import { DomainException, NotFoundException, type LoggerService } from '@modules/shared';
import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type UserDetail } from '../../../domain/repositories/admin-query.repository';
import { GetUserDetailQuery } from './get-user-detail.query';

View File

@@ -1,6 +1,6 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { DomainException, LoggerService } from '@modules/shared';
import { DomainException, type LoggerService } from '@modules/shared';
import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type UserListResult } from '../../../domain/repositories/admin-query.repository';
import { GetUsersQuery } from './get-users.query';

View File

@@ -12,5 +12,3 @@ export { GetKycQueueQuery } from './get-kyc-queue/get-kyc-queue.query';
export { GetKycQueueHandler } from './get-kyc-queue/get-kyc-queue.handler';
export { GetAuditLogsQuery } from './get-audit-logs/get-audit-logs.query';
export { GetAuditLogsHandler } from './get-audit-logs/get-audit-logs.handler';
export { GetAiSettingsQuery } from './get-ai-settings/get-ai-settings.query';
export { GetAiSettingsHandler, type AiSettingsDto } from './get-ai-settings/get-ai-settings.handler';

View File

@@ -1,159 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@modules/shared';
/**
* SystemSettings service — read/write the SystemSetting key/value store for
* runtime-configurable platform settings (currently: Claude/Anthropic AI
* credentials).
*
* TODO(hardening): secret values are persisted as plain strings. A future
* iteration should encrypt `isSecret` entries at rest (libsodium / KMS).
*/
export const AI_SETTING_KEYS = {
apiUrl: 'ai.api_url',
apiKey: 'ai.api_key',
model: 'ai.model',
} as const;
export const AI_DEFAULTS = {
apiUrl: 'https://api.anthropic.com/v1',
model: 'claude-opus-4-5',
} as const;
export interface AiSettingsInternal {
apiUrl: string;
apiKey: string | null;
model: string;
updatedAt: Date | null;
}
export interface UpdateAiSettingsInput {
apiUrl?: string;
apiKey?: string; // pass empty string to clear, '__UNCHANGED__' to leave, undefined to leave
model?: string;
updatedBy?: string | null;
}
export const UNCHANGED_SENTINEL = '__UNCHANGED__';
@Injectable()
export class SystemSettingsService {
constructor(private readonly prisma: PrismaService) {}
/**
* Read the current AI settings including the raw (unmasked) API key. Intended
* for backend runtime consumers only — never return the raw key over HTTP.
*/
async getAiSettings(): Promise<AiSettingsInternal> {
const rows = await this.prisma.systemSetting.findMany({
where: {
key: {
in: [AI_SETTING_KEYS.apiUrl, AI_SETTING_KEYS.apiKey, AI_SETTING_KEYS.model],
},
},
});
const byKey = new Map(rows.map((r) => [r.key, r]));
const apiUrlRow = byKey.get(AI_SETTING_KEYS.apiUrl);
const apiKeyRow = byKey.get(AI_SETTING_KEYS.apiKey);
const modelRow = byKey.get(AI_SETTING_KEYS.model);
const latestUpdatedAt = rows.reduce<Date | null>((acc, r) => {
if (!acc || r.updatedAt > acc) return r.updatedAt;
return acc;
}, null);
return {
apiUrl: apiUrlRow?.value || AI_DEFAULTS.apiUrl,
apiKey: apiKeyRow?.value || null,
model: modelRow?.value || AI_DEFAULTS.model,
updatedAt: latestUpdatedAt,
};
}
async updateAiSettings(input: UpdateAiSettingsInput): Promise<AiSettingsInternal> {
const updatedBy = input.updatedBy ?? null;
const ops: Array<Promise<unknown>> = [];
if (input.apiUrl !== undefined) {
ops.push(
this.prisma.systemSetting.upsert({
where: { key: AI_SETTING_KEYS.apiUrl },
create: {
key: AI_SETTING_KEYS.apiUrl,
value: input.apiUrl,
valueType: 'string',
isSecret: false,
updatedBy,
},
update: { value: input.apiUrl, valueType: 'string', isSecret: false, updatedBy },
}),
);
}
if (input.model !== undefined) {
ops.push(
this.prisma.systemSetting.upsert({
where: { key: AI_SETTING_KEYS.model },
create: {
key: AI_SETTING_KEYS.model,
value: input.model,
valueType: 'string',
isSecret: false,
updatedBy,
},
update: { value: input.model, valueType: 'string', isSecret: false, updatedBy },
}),
);
}
// apiKey semantics:
// - undefined → do nothing
// - '__UNCHANGED__' → do nothing (frontend round-trip sentinel)
// - '' (empty) → explicit clear
// - any other string → overwrite
if (input.apiKey !== undefined && input.apiKey !== UNCHANGED_SENTINEL) {
if (input.apiKey === '') {
ops.push(
this.prisma.systemSetting.deleteMany({ where: { key: AI_SETTING_KEYS.apiKey } }),
);
} else {
ops.push(
this.prisma.systemSetting.upsert({
where: { key: AI_SETTING_KEYS.apiKey },
create: {
key: AI_SETTING_KEYS.apiKey,
value: input.apiKey,
valueType: 'secret',
isSecret: true,
updatedBy,
},
update: {
value: input.apiKey,
valueType: 'secret',
isSecret: true,
updatedBy,
},
}),
);
}
}
await Promise.all(ops);
return this.getAiSettings();
}
/**
* Mask an Anthropic API key: keep first 7 chars + `...` + last 4 chars.
* Example: `sk-ant-api03-abc...wxyz` → `sk-ant-...wxyz`.
*/
static maskApiKey(raw: string | null): string | null {
if (!raw) return null;
if (raw.length <= 11) {
// Too short to meaningfully mask — still hide the middle.
return `${raw.slice(0, Math.min(4, raw.length))}...`;
}
return `${raw.slice(0, 7)}...${raw.slice(-4)}`;
}
}

View File

@@ -14,13 +14,3 @@ export {
type AuditLogListResult,
type CreateAuditLogInput,
} from './audit-log.repository';
export {
MODERATION_AUDIT_LOG_REPOSITORY,
IModerationAuditLogRepository,
type ModerationAction,
type ModerationTargetType,
type ModerationAuditLogEntry,
type ModerationAuditLogListParams,
type ModerationAuditLogListResult,
type CreateModerationAuditLogInput,
} from './moderation-audit-log.repository';

View File

@@ -1,57 +0,0 @@
export const MODERATION_AUDIT_LOG_REPOSITORY = Symbol(
'MODERATION_AUDIT_LOG_REPOSITORY',
);
export type ModerationAction = 'approve' | 'reject' | 'flag' | 'edit' | string;
export type ModerationTargetType =
| 'listing'
| 'property'
| 'inquiry'
| 'review'
| string;
export interface ModerationAuditLogEntry {
id: string;
targetType: string;
targetId: string;
action: string;
moderatorId: string;
reason: string | null;
metadata: Record<string, unknown> | null;
createdAt: Date;
}
export interface CreateModerationAuditLogInput {
targetType: ModerationTargetType;
targetId: string;
action: ModerationAction;
moderatorId: string;
reason?: string;
metadata?: Record<string, unknown>;
}
export interface ModerationAuditLogListParams {
page: number;
limit: number;
targetType?: string;
targetId?: string;
action?: string;
moderatorId?: string;
startDate?: Date;
endDate?: Date;
}
export interface ModerationAuditLogListResult {
data: ModerationAuditLogEntry[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface IModerationAuditLogRepository {
create(input: CreateModerationAuditLogInput): Promise<ModerationAuditLogEntry>;
findAll(
params: ModerationAuditLogListParams,
): Promise<ModerationAuditLogListResult>;
}

View File

@@ -1,5 +1,4 @@
export { AdminModule } from './admin.module';
export { SystemSettingsService } from './application/services/system-settings.service';
export { ListingApprovedEvent } from './domain/events/listing-approved.event';
export { ListingRejectedEvent } from './domain/events/listing-rejected.event';
export {

View File

@@ -1,118 +0,0 @@
/**
* Integration spec for the ModerationAuditLog repository introduced in
* migration 20260420010000_add_moderation_audit_log (TEC-2926).
*
* Requires a live PostgreSQL test database with the migration applied.
* Runs under `pnpm --filter api test:integration`.
*/
import { PrismaClient } from '@prisma/client';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PrismaModerationAuditLogRepository } from '../repositories/prisma-moderation-audit-log.repository';
const prisma = new PrismaClient();
// The repository only depends on prisma.moderationAuditLog — cast is safe here.
const repo = new PrismaModerationAuditLogRepository(prisma as any);
const MODERATOR_A = '00000000-0000-4000-8000-00000000a001';
const MODERATOR_B = '00000000-0000-4000-8000-00000000a002';
const LISTING_A = '00000000-0000-4000-8000-00000000b001';
const LISTING_B = '00000000-0000-4000-8000-00000000b002';
describe('ModerationAuditLog repository (TEC-2926)', () => {
beforeAll(async () => {
await prisma.moderationAuditLog.deleteMany({
where: { moderatorId: { in: [MODERATOR_A, MODERATOR_B] } },
});
});
afterAll(async () => {
await prisma.moderationAuditLog.deleteMany({
where: { moderatorId: { in: [MODERATOR_A, MODERATOR_B] } },
});
await prisma.$disconnect();
});
it('persists a row with the expected columns', async () => {
const entry = await repo.create({
targetType: 'listing',
targetId: LISTING_A,
action: 'approve',
moderatorId: MODERATOR_A,
reason: 'clean',
metadata: { score: 0.98 },
});
expect(entry.id).toBeTruthy();
expect(entry.targetType).toBe('listing');
expect(entry.targetId).toBe(LISTING_A);
expect(entry.action).toBe('approve');
expect(entry.moderatorId).toBe(MODERATOR_A);
expect(entry.reason).toBe('clean');
expect(entry.metadata).toEqual({ score: 0.98 });
expect(entry.createdAt).toBeInstanceOf(Date);
});
it('filters by targetType + targetId', async () => {
await repo.create({
targetType: 'listing',
targetId: LISTING_B,
action: 'reject',
moderatorId: MODERATOR_B,
reason: 'spam',
});
await repo.create({
targetType: 'property',
targetId: LISTING_B,
action: 'flag',
moderatorId: MODERATOR_B,
});
const listingOnly = await repo.findAll({
page: 1,
limit: 50,
targetType: 'listing',
targetId: LISTING_B,
});
expect(listingOnly.total).toBeGreaterThanOrEqual(1);
for (const row of listingOnly.data) {
expect(row.targetType).toBe('listing');
expect(row.targetId).toBe(LISTING_B);
}
});
it('filters by moderatorId and by action', async () => {
const byModerator = await repo.findAll({
page: 1,
limit: 50,
moderatorId: MODERATOR_A,
});
expect(byModerator.total).toBeGreaterThanOrEqual(1);
for (const row of byModerator.data) {
expect(row.moderatorId).toBe(MODERATOR_A);
}
const rejects = await repo.findAll({
page: 1,
limit: 50,
moderatorId: MODERATOR_B,
action: 'reject',
});
for (const row of rejects.data) {
expect(row.action).toBe('reject');
expect(row.moderatorId).toBe(MODERATOR_B);
}
});
it('orders newest first and paginates', async () => {
const page1 = await repo.findAll({
page: 1,
limit: 1,
moderatorId: MODERATOR_B,
});
expect(page1.data.length).toBe(1);
expect(page1.limit).toBe(1);
expect(page1.page).toBe(1);
expect(page1.totalPages).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -1,25 +0,0 @@
import { Injectable } from '@nestjs/common';
import {
type AiRuntimeConfig,
type IAIConfigProvider,
} from '@modules/shared';
import { SystemSettingsService } from '../../application/services/system-settings.service';
/**
* Adapter that exposes the admin-owned `SystemSettingsService` through the
* shared `IAIConfigProvider` port. Lets analytics (and any other module)
* read AI runtime config without importing AdminModule (A-09).
*/
@Injectable()
export class SystemSettingsAiConfigProvider implements IAIConfigProvider {
constructor(private readonly systemSettings: SystemSettingsService) {}
async getAiConfig(): Promise<AiRuntimeConfig> {
const settings = await this.systemSettings.getAiSettings();
return {
apiUrl: settings.apiUrl,
apiKey: settings.apiKey,
model: settings.model,
};
}
}

View File

@@ -1,4 +1,3 @@
import { Prisma } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import {
type DashboardStats,
@@ -44,76 +43,67 @@ export async function getDashboardStats(prisma: PrismaService): Promise<Dashboar
};
}
// ---------------------------------------------------------------------------
// Simple in-process cache for revenue stats (TTL = 60 seconds)
// ---------------------------------------------------------------------------
interface RevenueCacheEntry {
expiresAt: number;
data: RevenueStatsItem[];
}
const revenueStatsCache = new Map<string, RevenueCacheEntry>();
function buildCacheKey(startDate: Date, endDate: Date, groupBy: string): string {
return `${startDate.toISOString()}|${endDate.toISOString()}|${groupBy}`;
}
// Raw row returned by Postgres for the aggregation query
interface RevenueRawRow {
period: string;
total_revenue: bigint;
subscription_revenue: bigint;
listing_fee_revenue: bigint;
featured_listing_revenue: bigint;
transaction_count: bigint;
}
export async function getRevenueStats(
prisma: PrismaService,
startDate: Date,
endDate: Date,
groupBy: 'day' | 'month',
): Promise<RevenueStatsItem[]> {
const cacheKey = buildCacheKey(startDate, endDate, groupBy);
const cached = revenueStatsCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.data;
const payments = await prisma.payment.findMany({
where: {
status: 'COMPLETED',
createdAt: { gte: startDate, lte: endDate },
},
select: {
type: true,
amountVND: true,
createdAt: true,
},
orderBy: { createdAt: 'asc' },
});
const grouped = new Map<string, {
totalRevenue: bigint;
subscriptionRevenue: bigint;
listingFeeRevenue: bigint;
featuredListingRevenue: bigint;
transactionCount: number;
}>();
for (const payment of payments) {
const period = groupBy === 'day'
? payment.createdAt.toISOString().slice(0, 10)
: payment.createdAt.toISOString().slice(0, 7);
if (!grouped.has(period)) {
grouped.set(period, {
totalRevenue: 0n,
subscriptionRevenue: 0n,
listingFeeRevenue: 0n,
featuredListingRevenue: 0n,
transactionCount: 0,
});
}
const stats = grouped.get(period)!;
stats.totalRevenue += payment.amountVND;
stats.transactionCount++;
switch (payment.type) {
case 'SUBSCRIPTION':
stats.subscriptionRevenue += payment.amountVND;
break;
case 'LISTING_FEE':
stats.listingFeeRevenue += payment.amountVND;
break;
case 'FEATURED_LISTING':
stats.featuredListingRevenue += payment.amountVND;
break;
}
}
// Postgres can't prove that `DATE_TRUNC($n, ...)` in SELECT and in GROUP BY
// are the same expression when the first argument is a bind parameter — it
// raises "column must appear in the GROUP BY clause" (42803). Inline the
// unit as a raw fragment instead. `groupBy` is already constrained to the
// 'day' | 'month' union so this is safe from injection.
const truncUnit = groupBy === 'day' ? Prisma.sql`'day'` : Prisma.sql`'month'`;
const rows = await prisma.$queryRaw<RevenueRawRow[]>`
SELECT
TO_CHAR(DATE_TRUNC(${truncUnit}, "createdAt"), 'YYYY-MM-DD') AS period,
SUM("amountVND") AS total_revenue,
SUM(CASE WHEN type = 'SUBSCRIPTION' THEN "amountVND" ELSE 0 END) AS subscription_revenue,
SUM(CASE WHEN type = 'LISTING_FEE' THEN "amountVND" ELSE 0 END) AS listing_fee_revenue,
SUM(CASE WHEN type = 'FEATURED_LISTING' THEN "amountVND" ELSE 0 END) AS featured_listing_revenue,
COUNT(*) AS transaction_count
FROM "Payment"
WHERE status = 'COMPLETED'
AND "createdAt" >= ${startDate}
AND "createdAt" <= ${endDate}
GROUP BY DATE_TRUNC(${truncUnit}, "createdAt")
ORDER BY DATE_TRUNC(${truncUnit}, "createdAt") ASC
`;
const data: RevenueStatsItem[] = rows.map((row) => ({
period: row.period,
totalRevenue: BigInt(row.total_revenue),
subscriptionRevenue: BigInt(row.subscription_revenue),
listingFeeRevenue: BigInt(row.listing_fee_revenue),
featuredListingRevenue: BigInt(row.featured_listing_revenue),
transactionCount: Number(row.transaction_count),
return Array.from(grouped.entries()).map(([period, stats]) => ({
period,
...stats,
}));
revenueStatsCache.set(cacheKey, { expiresAt: Date.now() + 60_000, data });
return data;
}

View File

@@ -1,3 +1,2 @@
export { PrismaAdminQueryRepository } from './prisma-admin-query.repository';
export { PrismaAuditLogRepository } from './prisma-audit-log.repository';
export { PrismaModerationAuditLogRepository } from './prisma-moderation-audit-log.repository';

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@modules/shared';
import { type PrismaService } from '@modules/shared';
import {
type IAdminQueryRepository,
type ModerationQueueResult,

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { type AdminAction, type AuditTargetType, type Prisma } from '@prisma/client';
import { PrismaService } from '@modules/shared';
import { type PrismaService } from '@modules/shared';
import {
type IAuditLogRepository,
type AuditLogEntry,

View File

@@ -1,105 +0,0 @@
import { Injectable } from '@nestjs/common';
import { type Prisma } from '@prisma/client';
import { PrismaService } from '@modules/shared';
import {
type CreateModerationAuditLogInput,
type IModerationAuditLogRepository,
type ModerationAuditLogEntry,
type ModerationAuditLogListParams,
type ModerationAuditLogListResult,
} from '../../domain/repositories/moderation-audit-log.repository';
@Injectable()
export class PrismaModerationAuditLogRepository
implements IModerationAuditLogRepository
{
constructor(private readonly prisma: PrismaService) {}
async create(
input: CreateModerationAuditLogInput,
): Promise<ModerationAuditLogEntry> {
const record = await this.prisma.moderationAuditLog.create({
data: {
targetType: input.targetType,
targetId: input.targetId,
action: input.action,
moderatorId: input.moderatorId,
reason: input.reason ?? null,
metadata:
(input.metadata as Prisma.InputJsonValue | undefined) ?? undefined,
},
});
return this.toEntry(record);
}
async findAll(
params: ModerationAuditLogListParams,
): Promise<ModerationAuditLogListResult> {
const {
page,
limit,
targetType,
targetId,
action,
moderatorId,
startDate,
endDate,
} = params;
const safePage = Math.max(1, Math.floor(page));
const safeLimit = Math.min(Math.max(1, Math.floor(limit)), 100);
const skip = (safePage - 1) * safeLimit;
const where: Prisma.ModerationAuditLogWhereInput = {};
if (targetType) where.targetType = targetType;
if (targetId) where.targetId = targetId;
if (action) where.action = action;
if (moderatorId) where.moderatorId = moderatorId;
if (startDate || endDate) {
where.createdAt = {};
if (startDate) where.createdAt.gte = startDate;
if (endDate) where.createdAt.lte = endDate;
}
const [records, total] = await Promise.all([
this.prisma.moderationAuditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: safeLimit,
}),
this.prisma.moderationAuditLog.count({ where }),
]);
return {
data: records.map((r) => this.toEntry(r)),
total,
page: safePage,
limit: safeLimit,
totalPages: Math.ceil(total / safeLimit),
};
}
private toEntry(record: {
id: string;
targetType: string;
targetId: string;
action: string;
moderatorId: string;
reason: string | null;
metadata: Prisma.JsonValue | null;
createdAt: Date;
}): ModerationAuditLogEntry {
return {
id: record.id,
targetType: record.targetType,
targetId: record.targetId,
action: record.action,
moderatorId: record.moderatorId,
reason: record.reason,
metadata: record.metadata as Record<string, unknown> | null,
createdAt: record.createdAt,
};
}
}

View File

@@ -1,48 +0,0 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
import { GetModerationAuditLogsQuery } from '../../application/queries/get-moderation-audit-logs/get-moderation-audit-logs.query';
import { type ModerationAuditLogListResult } from '../../domain/repositories/moderation-audit-log.repository';
import { GetModerationAuditLogsQueryDto } from '../dto/get-moderation-audit-logs-query.dto';
@ApiTags('admin')
@ApiBearerAuth('JWT')
@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
export class AdminModerationAuditController {
constructor(private readonly queryBus: QueryBus) {}
@Get('moderation/audit-logs')
@ApiOperation({
summary: 'Get moderation audit logs (approve/reject/flag/edit)',
})
@ApiResponse({
status: 200,
description: 'Moderation audit logs retrieved successfully',
})
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async getModerationAuditLogs(
@Query() query: GetModerationAuditLogsQueryDto,
): Promise<ModerationAuditLogListResult> {
return this.queryBus.execute(
new GetModerationAuditLogsQuery(
query.page ?? 1,
query.limit ?? 20,
query.targetType,
query.targetId,
query.action,
query.moderatorId,
query.startDate ? new Date(query.startDate) : undefined,
query.endDate ? new Date(query.endDate) : undefined,
),
);
}
}

View File

@@ -2,19 +2,13 @@ import {
Body,
Controller,
Get,
Ip,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam, ApiQuery } from '@nestjs/swagger';
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
import {
AdminFeatureListingCommand,
type AdminFeatureListingResult,
} from '@modules/listings';
import { ApproveKycCommand } from '../../application/commands/approve-kyc/approve-kyc.command';
import { type ApproveKycResult } from '../../application/commands/approve-kyc/approve-kyc.handler';
import { ApproveListingCommand } from '../../application/commands/approve-listing/approve-listing.command';
@@ -25,20 +19,17 @@ import { RejectKycCommand } from '../../application/commands/reject-kyc/reject-k
import { type RejectKycResult } from '../../application/commands/reject-kyc/reject-kyc.handler';
import { RejectListingCommand } from '../../application/commands/reject-listing/reject-listing.command';
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 { GetModerationQueueQuery } from '../../application/queries/get-moderation-queue/get-moderation-queue.query';
import {
type ModerationQueueResult,
type KycQueueResult,
} from '../../domain/repositories/admin-query.repository';
import { AdminFeatureListingDto } from '../dto/admin-feature-listing.dto';
import { ApproveKycDto } from '../dto/approve-kyc.dto';
import { ApproveListingDto } from '../dto/approve-listing.dto';
import { BulkModerateDto } from '../dto/bulk-moderate.dto';
import { RejectKycDto } from '../dto/reject-kyc.dto';
import { RejectListingDto } from '../dto/reject-listing.dto';
import { type ApproveKycDto } from '../dto/approve-kyc.dto';
import { type ApproveListingDto } from '../dto/approve-listing.dto';
import { type BulkModerateDto } from '../dto/bulk-moderate.dto';
import { type RejectKycDto } from '../dto/reject-kyc.dto';
import { type RejectListingDto } from '../dto/reject-listing.dto';
@ApiTags('admin')
@ApiBearerAuth('JWT')
@@ -114,54 +105,6 @@ export class AdminModerationController {
);
}
@Post('listings/:id/feature')
@ApiOperation({
summary: 'Admin: feature or unfeature a listing manually (audited, no payment)',
})
@ApiParam({ name: 'id', description: 'Listing UUID' })
@ApiResponse({ status: 201, description: 'Listing featured state updated successfully' })
@ApiResponse({ status: 400, description: 'Validation error' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async adminFeatureListing(
@Param('id') id: string,
@Body() dto: AdminFeatureListingDto,
@CurrentUser() user: JwtPayload,
@Ip() ip: string,
): Promise<AdminFeatureListingResult> {
return this.commandBus.execute(
new AdminFeatureListingCommand(
id,
user.sub,
dto.action,
dto.durationDays ?? null,
dto.reason,
ip ?? null,
),
);
}
// ── Flagged Listings (User Reports) ──
@Get('flagged-listings')
@ApiOperation({ summary: 'Get listings flagged by users (báo cáo)' })
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default 1)' })
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default 20)' })
@ApiResponse({ status: 200, description: 'Flagged listings queue retrieved successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async getFlaggedListings(
@Query('page') page?: string,
@Query('limit') limit?: string,
): Promise<FlaggedListingsResult> {
return this.queryBus.execute(
new GetFlaggedListingsQuery(
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
),
);
}
// ── KYC ──
@Get('kyc')

View File

@@ -8,22 +8,15 @@ import {
Query,
UseGuards,
} from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger';
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
import { AdjustSubscriptionCommand } from '../../application/commands/adjust-subscription/adjust-subscription.command';
import { type AdjustSubscriptionResult } from '../../application/commands/adjust-subscription/adjust-subscription.handler';
import { BanUserCommand } from '../../application/commands/ban-user/ban-user.command';
import { type BanUserResult } from '../../application/commands/ban-user/ban-user.handler';
import { ProvisionDeveloperCommand } from '../../application/commands/provision-developer/provision-developer.command';
import { type ProvisionDeveloperResult } from '../../application/commands/provision-developer/provision-developer.handler';
import { ProvisionParkOperatorCommand } from '../../application/commands/provision-park-operator/provision-park-operator.command';
import { type ProvisionParkOperatorResult } from '../../application/commands/provision-park-operator/provision-park-operator.handler';
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 { type UpdateUserStatusResult } from '../../application/commands/update-user-status/update-user-status.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 { GetDashboardStatsQuery } from '../../application/queries/get-dashboard-stats/get-dashboard-stats.query';
import { GetRevenueStatsQuery } from '../../application/queries/get-revenue-stats/get-revenue-stats.query';
@@ -36,15 +29,12 @@ import {
type UserDetail,
} from '../../domain/repositories/admin-query.repository';
import { type AuditLogListResult } from '../../domain/repositories/audit-log.repository';
import { AdjustSubscriptionDto } from '../dto/adjust-subscription.dto';
import { BanUserDto } from '../dto/ban-user.dto';
import { GetAuditLogsQueryDto } from '../dto/get-audit-logs-query.dto';
import { GetUsersQueryDto } from '../dto/get-users-query.dto';
import { ProvisionDeveloperDto } from '../dto/provision-developer.dto';
import { ProvisionParkOperatorDto } from '../dto/provision-park-operator.dto';
import { RevenueStatsDto } from '../dto/revenue-stats.dto';
import { UpdateAiSettingsDto } from '../dto/update-ai-settings.dto';
import { UpdateUserStatusDto } from '../dto/update-user-status.dto';
import { type AdjustSubscriptionDto } from '../dto/adjust-subscription.dto';
import { type BanUserDto } from '../dto/ban-user.dto';
import { type GetAuditLogsQueryDto } from '../dto/get-audit-logs-query.dto';
import { type GetUsersQueryDto } from '../dto/get-users-query.dto';
import { type RevenueStatsDto } from '../dto/revenue-stats.dto';
import { type UpdateUserStatusDto } from '../dto/update-user-status.dto';
@ApiTags('admin')
@ApiBearerAuth('JWT')
@@ -138,23 +128,7 @@ export class AdminController {
@Get('dashboard')
@ApiOperation({ summary: 'Get admin dashboard statistics' })
@ApiResponse({
status: 200,
description: 'Dashboard stats retrieved successfully',
schema: {
example: {
totalUsers: 12840,
totalListings: 5432,
activeListings: 4021,
pendingModerationCount: 38,
totalAgents: 612,
verifiedAgents: 417,
totalTransactions: 980,
newUsersLast30Days: 246,
newListingsLast30Days: 183,
},
},
})
@ApiResponse({ status: 200, description: 'Dashboard stats retrieved successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async getDashboardStats(): Promise<DashboardStats> {
@@ -181,83 +155,6 @@ export class AdminController {
);
}
// ── AI Settings ──
@Get('settings/ai')
@ApiOperation({ summary: 'Get AI provider (Claude) settings' })
@ApiResponse({ status: 200, description: 'AI settings retrieved successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async getAiSettings(): Promise<AiSettingsDto> {
return this.queryBus.execute(new GetAiSettingsQuery());
}
@Patch('settings/ai')
@ApiOperation({ summary: 'Update AI provider (Claude) settings' })
@ApiResponse({ status: 200, description: 'AI settings updated successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async updateAiSettings(
@Body() dto: UpdateAiSettingsDto,
@CurrentUser() user: JwtPayload,
): Promise<AiSettingsDto> {
return this.commandBus.execute(
new UpdateAiSettingsCommand(user.sub, dto.apiUrl, dto.apiKey, dto.model),
);
}
// ── B2B Account Provisioning ──────────────────────────────────────
@Post('accounts/developers')
@ApiOperation({
summary: 'Tạo tài khoản CĐT (DEVELOPER) — admin only',
description:
'Tạo mới một user với role=DEVELOPER và tuỳ chọn gán quyền sở hữu các ProjectDevelopment hiện có. Dự án đã có owner khác sẽ bị từ chối.',
})
@ApiResponse({ status: 201, description: 'Tạo tài khoản CĐT thành công' })
@ApiResponse({ status: 400, description: 'Dữ liệu không hợp lệ' })
@ApiResponse({ status: 401, description: 'Chưa xác thực' })
@ApiResponse({ status: 403, description: 'Yêu cầu role ADMIN' })
@ApiResponse({ status: 409, description: 'Số điện thoại/email đã tồn tại hoặc dự án đã có CĐT khác' })
async provisionDeveloper(
@Body() dto: ProvisionDeveloperDto,
): Promise<ProvisionDeveloperResult> {
return this.commandBus.execute(
new ProvisionDeveloperCommand(
dto.phone,
dto.password,
dto.fullName,
dto.email ?? null,
dto.projectIds ?? [],
),
);
}
@Post('accounts/park-operators')
@ApiOperation({
summary: 'Tạo tài khoản vận hành KCN (PARK_OPERATOR) — admin only',
description:
'Tạo mới một user với role=PARK_OPERATOR và tuỳ chọn gán quyền vận hành các IndustrialPark hiện có.',
})
@ApiResponse({ status: 201, description: 'Tạo tài khoản PARK_OPERATOR thành công' })
@ApiResponse({ status: 400, description: 'Dữ liệu không hợp lệ' })
@ApiResponse({ status: 401, description: 'Chưa xác thực' })
@ApiResponse({ status: 403, description: 'Yêu cầu role ADMIN' })
@ApiResponse({ status: 409, description: 'Số điện thoại/email đã tồn tại hoặc KCN đã có đơn vị khác' })
async provisionParkOperator(
@Body() dto: ProvisionParkOperatorDto,
): Promise<ProvisionParkOperatorResult> {
return this.commandBus.execute(
new ProvisionParkOperatorCommand(
dto.phone,
dto.password,
dto.fullName,
dto.email ?? null,
dto.parkIds ?? [],
),
);
}
// ── Audit Logs ──
@Get('audit-logs')

View File

@@ -1,36 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsString, MinLength, ValidateIf } from 'class-validator';
const ALLOWED_DURATIONS = [3, 7, 14, 30, 60, 90] as const;
export type AdminFeatureDuration = (typeof ALLOWED_DURATIONS)[number];
export class AdminFeatureListingDto {
@ApiProperty({
enum: ['feature', 'unfeature'],
example: 'feature',
description: 'Bật hoặc gỡ tin nổi bật thủ công',
})
@IsIn(['feature', 'unfeature'])
action!: 'feature' | 'unfeature';
@ApiPropertyOptional({
enum: ALLOWED_DURATIONS,
example: 7,
description: 'Số ngày featured (bắt buộc khi action=feature)',
})
@ValidateIf((o: AdminFeatureListingDto) => o.action === 'feature')
@Type(() => Number)
@IsInt()
@IsIn([...ALLOWED_DURATIONS])
@IsOptional()
durationDays?: AdminFeatureDuration;
@ApiProperty({
example: 'Đền bù lỗi hiển thị featured trong 3 ngày qua',
description: 'Lý do cho audit log (tối thiểu 5 ký tự)',
})
@IsString()
@MinLength(5)
reason!: string;
}

View File

@@ -1,67 +0,0 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsOptional, IsString, IsInt, Min, Max, IsDateString } from 'class-validator';
export class GetModerationAuditLogsQueryDto {
@ApiPropertyOptional({ description: 'Page number', example: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({
description: 'Items per page',
example: 20,
minimum: 1,
maximum: 100,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number;
@ApiPropertyOptional({
description: 'Filter by target type, e.g. listing | property | inquiry',
example: 'listing',
})
@IsOptional()
@IsString()
targetType?: string;
@ApiPropertyOptional({ description: 'Filter by target entity ID' })
@IsOptional()
@IsString()
targetId?: string;
@ApiPropertyOptional({
description: 'Filter by moderation action, e.g. approve | reject | flag | edit',
example: 'approve',
})
@IsOptional()
@IsString()
action?: string;
@ApiPropertyOptional({ description: 'Filter by moderator user ID' })
@IsOptional()
@IsString()
moderatorId?: string;
@ApiPropertyOptional({
description: 'Start date filter (ISO 8601)',
example: '2026-01-01T00:00:00.000Z',
})
@IsOptional()
@IsDateString()
startDate?: string;
@ApiPropertyOptional({
description: 'End date filter (ISO 8601)',
example: '2026-12-31T23:59:59.999Z',
})
@IsOptional()
@IsDateString()
endDate?: string;
}

View File

@@ -9,4 +9,3 @@ export { ApproveKycDto } from './approve-kyc.dto';
export { RejectKycDto } from './reject-kyc.dto';
export { BulkModerateDto } from './bulk-moderate.dto';
export { GetAuditLogsQueryDto } from './get-audit-logs-query.dto';
export { UpdateAiSettingsDto } from './update-ai-settings.dto';

View File

@@ -1,41 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayUnique,
IsArray,
IsEmail,
IsOptional,
IsString,
MinLength,
} from 'class-validator';
export class ProvisionDeveloperDto {
@ApiProperty({ example: '+84912000001' })
@IsString()
phone!: string;
@ApiProperty({ example: 'Velik@2026', minLength: 8 })
@IsString()
@MinLength(8)
password!: string;
@ApiProperty({ example: 'CĐT Vinhomes' })
@IsString()
fullName!: string;
@ApiPropertyOptional({ example: 'cdt-vinhomes@goodgo.vn' })
@IsOptional()
@IsEmail()
email?: string;
@ApiPropertyOptional({
type: [String],
description:
'ID các dự án sẽ gán quyền sở hữu cho CĐT này (dự án phải chưa có owner).',
example: ['seed-project-001', 'seed-project-005'],
})
@IsOptional()
@IsArray()
@ArrayUnique()
@IsString({ each: true })
projectIds?: string[];
}

View File

@@ -1,41 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayUnique,
IsArray,
IsEmail,
IsOptional,
IsString,
MinLength,
} from 'class-validator';
export class ProvisionParkOperatorDto {
@ApiProperty({ example: '+84912000002' })
@IsString()
phone!: string;
@ApiProperty({ example: 'Velik@2026', minLength: 8 })
@IsString()
@MinLength(8)
password!: string;
@ApiProperty({ example: 'Vận hành KCN VSIP' })
@IsString()
fullName!: string;
@ApiPropertyOptional({ example: 'kcn-vsip@goodgo.vn' })
@IsOptional()
@IsEmail()
email?: string;
@ApiPropertyOptional({
type: [String],
description:
'ID các KCN sẽ gán quyền vận hành cho user này (KCN phải chưa có owner).',
example: ['seed-park-001'],
})
@IsOptional()
@IsArray()
@ArrayUnique()
@IsString({ each: true })
parkIds?: string[];
}

View File

@@ -1,31 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsIn, IsOptional, registerDecorator, ValidationArguments, ValidationOptions } from 'class-validator';
function MaxDateRangeDays(maxDays: number, validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'maxDateRangeDays',
target: (object as { constructor: new (...args: unknown[]) => unknown }).constructor,
propertyName,
options: validationOptions,
validator: {
validate(_value: unknown, args: ValidationArguments) {
const dto = args.object as RevenueStatsDto;
if (!dto.startDate || !dto.endDate) return true;
const start = new Date(dto.startDate);
const end = new Date(dto.endDate);
const diffMs = end.getTime() - start.getTime();
const diffDays = diffMs / (1000 * 60 * 60 * 24);
return diffDays >= 0 && diffDays <= maxDays;
},
defaultMessage(args: ValidationArguments) {
return `Date range must not exceed ${(args.constraints as number[])[0]} days`;
},
},
constraints: [maxDays],
});
};
}
import { IsDateString, IsIn, IsOptional } from 'class-validator';
export class RevenueStatsDto {
@ApiProperty({ description: 'Start date (ISO 8601)', example: '2025-01-01' })
@@ -34,7 +8,6 @@ export class RevenueStatsDto {
@ApiProperty({ description: 'End date (ISO 8601)', example: '2025-12-31' })
@IsDateString()
@MaxDateRangeDays(366, { message: 'Date range must not exceed 366 days' })
endDate!: string;
@ApiPropertyOptional({ description: 'Group results by day or month', enum: ['day', 'month'], default: 'month' })

View File

@@ -1,32 +0,0 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MaxLength } from 'class-validator';
export class UpdateAiSettingsDto {
@ApiPropertyOptional({
description: 'Base URL of the Anthropic-compatible API endpoint',
example: 'https://api.anthropic.com/v1',
})
@IsOptional()
@IsString()
@MaxLength(500)
apiUrl?: string;
@ApiPropertyOptional({
description:
'Raw API key. Send empty string to clear, "__UNCHANGED__" to leave untouched, omit to leave untouched.',
example: 'sk-ant-api03-xxxxxxxx',
})
@IsOptional()
@IsString()
@MaxLength(500)
apiKey?: string;
@ApiPropertyOptional({
description: 'Model identifier to use for Claude calls.',
example: 'claude-opus-4-5',
})
@IsOptional()
@IsString()
@MaxLength(120)
model?: string;
}

View File

@@ -1,7 +1,6 @@
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { RecalculateQualityScoreHandler } from './application/commands/recalculate-quality-score/recalculate-quality-score.handler';
import { UpgradeToAgentHandler } from './application/commands/upgrade-to-agent/upgrade-to-agent.handler';
import { ReviewEventsListener } from './application/listeners/review-events.listener';
import { GetAgentDashboardHandler } from './application/queries/get-agent-dashboard/get-agent-dashboard.handler';
import { GetAgentPublicProfileHandler } from './application/queries/get-agent-public-profile/get-agent-public-profile.handler';
@@ -9,7 +8,7 @@ import { AGENT_REPOSITORY } from './domain/repositories/agent.repository';
import { PrismaAgentRepository } from './infrastructure/repositories/prisma-agent.repository';
import { AgentsController } from './presentation/controllers/agents.controller';
const CommandHandlers = [RecalculateQualityScoreHandler, UpgradeToAgentHandler];
const CommandHandlers = [RecalculateQualityScoreHandler];
const QueryHandlers = [GetAgentDashboardHandler, GetAgentPublicProfileHandler];

View File

@@ -1,6 +1,6 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, LoggerService } from '@modules/shared';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, type LoggerService } from '@modules/shared';
import {
AGENT_REPOSITORY,
type IAgentRepository,

View File

@@ -1,9 +0,0 @@
export class UpgradeToAgentCommand {
constructor(
public readonly userId: string,
public readonly licenseNumber?: string,
public readonly agency?: string,
public readonly bio?: string,
public readonly serviceAreas?: string[],
) {}
}

View File

@@ -1,91 +0,0 @@
import { InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import {
ConflictException,
DomainException,
LoggerService,
NotFoundException,
PrismaService,
} from '@modules/shared';
import { UpgradeToAgentCommand } from './upgrade-to-agent.command';
export interface UpgradeToAgentResult {
agentId: string;
userId: string;
isVerified: false;
}
@CommandHandler(UpgradeToAgentCommand)
export class UpgradeToAgentHandler
implements ICommandHandler<UpgradeToAgentCommand>
{
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async execute(command: UpgradeToAgentCommand): Promise<UpgradeToAgentResult> {
try {
const user = await this.prisma.user.findUnique({
where: { id: command.userId },
select: { id: true, role: true, agent: { select: { id: true } } },
});
if (!user) {
throw new NotFoundException('User', command.userId);
}
if (user.role === 'AGENT') {
throw new ConflictException('Tài khoản đã là đại lý');
}
if (user.role === 'ADMIN') {
throw new ConflictException('Admin không cần nâng cấp đại lý');
}
if (user.agent) {
throw new ConflictException('Hồ sơ đại lý đã tồn tại cho tài khoản này');
}
const agentId = await this.prisma.$transaction(async (tx) => {
const agent = await tx.agent.create({
data: {
userId: command.userId,
licenseNumber: command.licenseNumber,
agency: command.agency,
bio: command.bio,
serviceAreas: command.serviceAreas ?? [],
isVerified: false,
},
select: { id: true },
});
await tx.user.update({
where: { id: command.userId },
data: { role: 'AGENT' },
});
return agent.id;
});
this.logger.log(
`User ${command.userId} upgraded to AGENT (agentId=${agentId})`,
'UpgradeToAgentHandler',
);
return {
agentId,
userId: command.userId,
isVerified: false,
};
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to upgrade user ${command.userId} to agent: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể nâng cấp tài khoản lên đại lý');
}
}
}

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { LoggerService } from '@modules/shared';
import { type LoggerService } from '@modules/shared';
import { RecalculateQualityScoreCommand } from '../commands/recalculate-quality-score/recalculate-quality-score.command';
@Injectable()

View File

@@ -1,6 +1,6 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { DomainException, NotFoundException, LoggerService } from '@modules/shared';
import { DomainException, NotFoundException, type LoggerService } from '@modules/shared';
import {
AGENT_REPOSITORY,
type AgentDashboardData,

View File

@@ -1,6 +1,6 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { DomainException, LoggerService } from '@modules/shared';
import { DomainException, type LoggerService } from '@modules/shared';
import {
AGENT_REPOSITORY,
type AgentPublicProfileData,

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@modules/shared';
import { type PrismaService } from '@modules/shared';
import { AgentEntity } from '../../domain/entities/agent.entity';
import {
type AgentDashboardData,

View File

@@ -1,5 +1,5 @@
import { Body, Controller, Get, NotFoundException, Param, Post, UseGuards } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { Controller, Get, NotFoundException, Param, Post, UseGuards } from '@nestjs/common';
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
import {
ApiBearerAuth,
ApiOperation,
@@ -15,12 +15,9 @@ import {
Roles,
} from '@modules/auth';
import { RecalculateQualityScoreCommand } from '../../application/commands/recalculate-quality-score/recalculate-quality-score.command';
import { UpgradeToAgentCommand } from '../../application/commands/upgrade-to-agent/upgrade-to-agent.command';
import { type UpgradeToAgentResult } from '../../application/commands/upgrade-to-agent/upgrade-to-agent.handler';
import { GetAgentDashboardQuery } from '../../application/queries/get-agent-dashboard/get-agent-dashboard.query';
import { GetAgentPublicProfileQuery } from '../../application/queries/get-agent-public-profile/get-agent-public-profile.query';
import { type AgentDashboardData, type AgentPublicProfileData } from '../../domain/repositories/agent.repository';
import { UpgradeToAgentDto } from '../dto/upgrade-to-agent.dto';
@ApiTags('agents')
@Controller('agents')
@@ -62,29 +59,6 @@ export class AgentsController {
return profile;
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Nâng cấp tài khoản lên đại lý' })
@ApiResponse({ status: 201, description: 'Tài khoản đã được nâng cấp lên đại lý (chưa xác minh)' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Không tìm thấy người dùng' })
@ApiResponse({ status: 409, description: 'Tài khoản đã là đại lý hoặc không được phép nâng cấp' })
@UseGuards(JwtAuthGuard)
@Post('me/upgrade')
async upgradeToAgent(
@CurrentUser() user: JwtPayload,
@Body() dto: UpgradeToAgentDto,
): Promise<UpgradeToAgentResult> {
return this.commandBus.execute(
new UpgradeToAgentCommand(
user.sub,
dto.licenseNumber,
dto.agency,
dto.bio,
dto.serviceAreas,
),
);
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Recalculate quality score (admin/system)' })
@ApiParam({ name: 'agentId', description: 'Agent ID' })

View File

@@ -1,30 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsOptional, IsString, MaxLength } from 'class-validator';
export class UpgradeToAgentDto {
@ApiProperty({ required: false, description: 'Số giấy phép hành nghề' })
@IsOptional()
@IsString()
licenseNumber?: string;
@ApiProperty({ required: false, description: 'Công ty / Sàn giao dịch' })
@IsOptional()
@IsString()
agency?: string;
@ApiProperty({ required: false, description: 'Giới thiệu bản thân' })
@IsOptional()
@IsString()
@MaxLength(1000)
bio?: string;
@ApiProperty({
required: false,
type: [String],
description: 'Khu vực hoạt động (slug quận/huyện)',
})
@IsOptional()
@IsArray()
@IsString({ each: true })
serviceAreas?: string[];
}

View File

@@ -1,98 +0,0 @@
# Analytics Module
Vietnamese real estate analytics endpoints: market reports, price trends, heatmaps, district stats, AVM (property valuation), neighborhood scores, POIs, AI-powered listing/project advice.
---
## Cache Metadata Pattern
All `/analytics/*` and `/avm/*` responses are **automatically wrapped** by `CacheMetaInterceptor` with a `cacheMeta` field that tells the frontend how fresh the data is.
### Response shape
```json
{
"data": { /* original payload */ },
"cacheMeta": {
"cachedAt": "2026-04-21T10:00:00.000Z",
"nextRefreshAt": "2026-04-21T10:15:00.000Z",
"source": "cache"
}
}
```
| Field | Type | Description |
|---|---|---|
| `cachedAt` | `string \| null` | ISO-8601 timestamp when the cache entry was written. `null` for legacy entries or when Redis is unavailable. |
| `nextRefreshAt` | `string \| null` | ISO-8601 timestamp when the entry will expire. Computed as `cachedAt + ttlSeconds`. `null` when `cachedAt` is null. |
| `source` | `"cache" \| "fresh"` | `"cache"` = data served from Redis; `"fresh"` = freshly fetched from DB/AI. |
### Frontend usage
Use `cacheMeta` to show a "Cập nhật lúc..." badge or tooltip:
```tsx
const label = cacheMeta.cachedAt
? `Cập nhật lúc ${new Date(cacheMeta.cachedAt).toLocaleTimeString('vi-VN')}`
: 'Dữ liệu mới nhất';
```
### How it works (for backend devs)
Three components cooperate:
1. **`CacheMetaStore`** (`shared/infrastructure/cache-meta.store.ts`)
An `AsyncLocalStorage<{ meta: CacheMeta | null }>` that lives for the duration of a single HTTP request. Provides request isolation so concurrent requests never share metadata.
2. **`CacheService.getOrSet`** (`shared/infrastructure/cache.service.ts`)
Cache entries are now stored as JSON envelopes `{ __v: data, cachedAt, ttlSeconds }`.
On each call, `getOrSet` writes the resolved metadata into the ALS store:
- **Cache hit** → reads `cachedAt`/`ttlSeconds` from the stored envelope, computes `nextRefreshAt`, writes `source: "cache"`.
- **Cache miss / fresh** → writes `cachedAt = now`, computes `nextRefreshAt`, writes `source: "fresh"`.
- **Redis unavailable** → writes `{ cachedAt: null, nextRefreshAt: null, source: "fresh" }`.
3. **`CacheMetaInterceptor`** (`analytics/presentation/interceptors/cache-meta.interceptor.ts`)
Applied at controller class level via `@UseInterceptors(CacheMetaInterceptor)`.
Wraps each response with the ALS-sourced `cacheMeta` after the handler resolves.
### Adding the pattern to a new controller
```ts
import { UseInterceptors } from '@nestjs/common';
import { CacheMetaInterceptor } from '../interceptors/cache-meta.interceptor';
@UseInterceptors(CacheMetaInterceptor)
@Controller('my-endpoint')
export class MyController { ... }
```
No other changes needed — `CacheService.getOrSet` handles metadata population automatically.
### Legacy cache entries
Entries written by previous versions of `CacheService` (plain JSON, no `__v` envelope) are still served correctly. `cacheMeta` will have `cachedAt: null` and `nextRefreshAt: null` for these entries.
---
## Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | `/analytics/market-report` | JWT + Quota | Market report per city/period |
| GET | `/analytics/price-trend` | JWT + Quota | Price trend per district |
| GET | `/analytics/heatmap` | JWT + Quota | Price heatmap |
| GET | `/analytics/district-stats` | JWT + Quota | District statistics |
| GET | `/analytics/valuation` | JWT + Quota | AVM property valuation |
| POST | `/analytics/valuation` | JWT + Quota + Rate limit | AVM from manual input |
| POST | `/analytics/valuation/batch` | JWT + Quota + Rate limit | Batch AVM (up to 50) |
| GET | `/analytics/valuation/history/:propertyId` | JWT + Quota | Valuation history |
| POST | `/analytics/valuation/compare` | JWT + Quota + Rate limit | Side-by-side comparison |
| GET | `/analytics/neighborhoods/:district/score` | Public | Neighborhood score |
| GET | `/analytics/pois/nearby` | Public | Nearby POIs |
| POST | `/analytics/listings/:id/ai-advice` | JWT | Claude AI advice for listing |
| POST | `/analytics/projects/:id/ai-advice` | JWT | Claude AI advice for project |
| POST | `/avm/batch` | JWT + Quota + Rate limit | AVM controller batch |
| GET | `/avm/history/:propertyId` | JWT + Quota | AVM controller history |
| GET | `/avm/compare` | JWT + Quota + Rate limit | AVM controller compare |
| GET | `/avm/explain` | JWT + Quota | Valuation explanation |
| POST | `/avm/industrial` | JWT + Quota + Rate limit | Industrial rent estimate |

View File

@@ -1,32 +1,18 @@
import { forwardRef, Module } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { makeCounterProvider, makeHistogramProvider } from '@willsoto/nestjs-prometheus';
import { AdminModule } from '@modules/admin';
import { ListingsModule } from '@modules/listings';
import { ProjectsModule } from '@modules/projects';
import { GenerateReportHandler } from './application/commands/generate-report/generate-report.handler';
import { TrackEventHandler } from './application/commands/track-event/track-event.handler';
import { UpdateMarketIndexHandler } from './application/commands/update-market-index/update-market-index.handler';
import { ListingCreatedModerationHandler } from './application/event-handlers/listing-created-moderation.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 { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap.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 { 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 { GetNearbyPOIsHandler } from './application/queries/get-nearby-pois/get-nearby-pois.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 { 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 { IndustrialValuationHandler } from './application/queries/industrial-valuation/industrial-valuation.handler';
import { PredictValuationHandler } from './application/queries/predict-valuation/predict-valuation.handler';
import { ValuationComparisonHandler } from './application/queries/valuation-comparison/valuation-comparison.handler';
import { ValuationExplanationHandler } from './application/queries/valuation-explanation/valuation-explanation.handler';
import { ValuationHistoryHandler } from './application/queries/valuation-history/valuation-history.handler';
import { MARKET_INDEX_REPOSITORY } from './domain/repositories/market-index.repository';
import { VALUATION_REPOSITORY } from './domain/repositories/valuation.repository';
@@ -37,17 +23,8 @@ import { PrismaValuationRepository } from './infrastructure/repositories/prisma-
import { AI_SERVICE_CLIENT, AiServiceClient } from './infrastructure/services/ai-service.client';
import { HttpAVMService } from './infrastructure/services/http-avm.service';
import { MarketIndexCronService } from './infrastructure/services/market-index-cron.service';
import {
HttpNeighborhoodScoreService,
PrismaNeighborhoodScoreService,
} from './infrastructure/services/neighborhood-score.service';
import { NeighborhoodScoreServiceImpl } from './infrastructure/services/neighborhood-score.service';
import { PrismaAVMService } from './infrastructure/services/prisma-avm.service';
import {
RefreshMaterializedViewCronService,
MATVIEW_REFRESH_TOTAL,
MATVIEW_REFRESH_DURATION,
MATVIEW_REFRESH_ERRORS,
} from './infrastructure/services/refresh-materialized-view-cron.service';
import { AnalyticsController } from './presentation/controllers/analytics.controller';
import { AvmController } from './presentation/controllers/avm.controller';
@@ -59,25 +36,15 @@ const CommandHandlers = [
const QueryHandlers = [
GetMarketReportHandler,
GetMarketHistoryHandler,
GetHeatmapHandler,
GetListingVolumeWardHandler,
GetPriceTrendHandler,
GetDistrictStatsHandler,
GetValuationHandler,
PredictValuationHandler,
BatchValuationHandler,
ValuationHistoryHandler,
ValuationComparisonHandler,
ValuationExplanationHandler,
GetNeighborhoodScoreHandler,
GetNearbyPOIsHandler,
IndustrialValuationHandler,
GetListingAiAdviceHandler,
GetProjectAiAdviceHandler,
GetMarketSnapshotHandler,
GetPriceMoversHandler,
GetTrendingAreasHandler,
];
const EventHandlers = [
@@ -85,12 +52,7 @@ const EventHandlers = [
];
@Module({
imports: [
CqrsModule,
forwardRef(() => ListingsModule),
ProjectsModule,
forwardRef(() => AdminModule), // for AI_CONFIG_PROVIDER (used by AI advice handlers)
],
imports: [CqrsModule],
controllers: [AnalyticsController, AvmController],
providers: [
// AI service client
@@ -104,31 +66,11 @@ const EventHandlers = [
PrismaAVMService,
{ provide: AVM_SERVICE, useClass: HttpAVMService },
// Neighborhood scoring: HTTP proxy → Python AI service, falls back to Prisma scoring
PrismaNeighborhoodScoreService,
{ provide: NEIGHBORHOOD_SCORE_SERVICE, useClass: HttpNeighborhoodScoreService },
// Neighborhood scoring
{ provide: NEIGHBORHOOD_SCORE_SERVICE, useClass: NeighborhoodScoreServiceImpl },
// Cron
MarketIndexCronService,
RefreshMaterializedViewCronService,
// Materialized-view refresh metrics
makeCounterProvider({
name: MATVIEW_REFRESH_TOTAL,
help: 'Total materialized-view refresh attempts',
labelNames: ['view', 'status'],
}),
makeHistogramProvider({
name: MATVIEW_REFRESH_DURATION,
help: 'Duration of materialized-view refresh in seconds',
labelNames: ['view'],
buckets: [1, 5, 15, 30, 60, 120, 300],
}),
makeCounterProvider({
name: MATVIEW_REFRESH_ERRORS,
help: 'Total materialized-view refresh errors',
labelNames: ['view', 'reason'],
}),
// CQRS
...CommandHandlers,

View File

@@ -1,6 +1,6 @@
import { InternalServerErrorException } from '@nestjs/common';
import { DomainException } from '@modules/shared';
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
import { DomainException } from '@modules/shared';
import {
type IAVMService,
type BatchValuationResult,

View File

@@ -1,149 +0,0 @@
import { NotFoundException } from '@nestjs/common';
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
import {
type IMarketIndexRepository,
type WardHeatmapDataPoint,
type ListingVolumeWardResult,
} from '../../domain/repositories/market-index.repository';
import { GetHeatmapHandler } from '../queries/get-heatmap/get-heatmap.handler';
import { GetHeatmapQuery } from '../queries/get-heatmap/get-heatmap.query';
import { GetListingVolumeWardHandler } from '../queries/get-listing-volume-ward/get-listing-volume-ward.handler';
import { GetListingVolumeWardQuery } from '../queries/get-listing-volume-ward/get-listing-volume-ward.query';
// Shared mock helpers
function makeRepo(): { [K in keyof IMarketIndexRepository]: ReturnType<typeof vi.fn> } {
return {
findById: vi.fn(),
findByKey: vi.fn(),
save: vi.fn(),
update: vi.fn(),
getMarketReport: vi.fn(),
getHeatmap: vi.fn(),
getHeatmapWard: vi.fn(),
getListingVolumeByWard: vi.fn(),
getPriceTrend: vi.fn(),
getDistrictStats: vi.fn(),
getMarketHistory: vi.fn(),
};
}
function makeCache(): CacheService {
return {
getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()),
} as unknown as CacheService;
}
function makeLogger() {
return { log: vi.fn(), error: vi.fn(), warn: vi.fn() } as any;
}
// ---------------------------------------------------------------------------
// GetHeatmapHandler — ward level
// ---------------------------------------------------------------------------
describe('GetHeatmapHandler — level=ward', () => {
let handler: GetHeatmapHandler;
let mockRepo: ReturnType<typeof makeRepo>;
beforeEach(() => {
mockRepo = makeRepo();
handler = new GetHeatmapHandler(mockRepo as any, makeCache(), makeLogger());
});
it('delegates to getHeatmapWard and returns level=ward in the dto', async () => {
const wardPoints: WardHeatmapDataPoint[] = [
{ ward: 'Phường Bến Nghé', district: 'Quận 1', city: 'Hồ Chí Minh', avgPriceM2: 130_000_000, totalListings: 42, medianPrice: '7000000000' },
{ ward: 'Phường Cầu Kho', district: 'Quận 1', city: 'Hồ Chí Minh', avgPriceM2: 100_000_000, totalListings: 18, medianPrice: '5500000000' },
];
mockRepo.getHeatmapWard.mockResolvedValue(wardPoints);
const query = new GetHeatmapQuery('Hồ Chí Minh', '2026-Q1', 'ward', 'Quận 1');
const result = await handler.execute(query);
expect(result.level).toBe('ward');
expect(result.city).toBe('Hồ Chí Minh');
expect(result.period).toBe('2026-Q1');
expect(result.dataPoints).toEqual(wardPoints);
expect(mockRepo.getHeatmapWard).toHaveBeenCalledWith('Hồ Chí Minh', '2026-Q1', 'Quận 1');
expect(mockRepo.getHeatmap).not.toHaveBeenCalled();
});
it('returns level=district when level is omitted (default)', async () => {
mockRepo.getHeatmap.mockResolvedValue([]);
const query = new GetHeatmapQuery('Hồ Chí Minh', '2026-Q1');
const result = await handler.execute(query);
expect(result.level).toBe('district');
expect(mockRepo.getHeatmap).toHaveBeenCalled();
expect(mockRepo.getHeatmapWard).not.toHaveBeenCalled();
});
it('returns empty dataPoints for ward level when no data', async () => {
mockRepo.getHeatmapWard.mockResolvedValue([]);
const query = new GetHeatmapQuery('Đà Nẵng', '2025-Q4', 'ward');
const result = await handler.execute(query);
expect(result.level).toBe('ward');
expect(result.dataPoints).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// GetListingVolumeWardHandler
// ---------------------------------------------------------------------------
describe('GetListingVolumeWardHandler', () => {
let handler: GetListingVolumeWardHandler;
let mockRepo: ReturnType<typeof makeRepo>;
beforeEach(() => {
mockRepo = makeRepo();
handler = new GetListingVolumeWardHandler(mockRepo as any, makeCache(), makeLogger());
});
it('returns listing volume for a ward and period', async () => {
const volume: ListingVolumeWardResult = {
ward: 'Phường Bến Nghé',
district: 'Quận 1',
city: 'Hồ Chí Minh',
period: '2026-Q1',
totalListings: 58,
avgPriceM2: 128_000_000,
medianPrice: '6800000000',
};
mockRepo.getListingVolumeByWard.mockResolvedValue(volume);
const query = new GetListingVolumeWardQuery('Phường Bến Nghé', '2026-Q1');
const result = await handler.execute(query);
expect(result).toEqual(volume);
expect(mockRepo.getListingVolumeByWard).toHaveBeenCalledWith('Phường Bến Nghé', '2026-Q1');
});
it('throws NotFoundException when no data found for the ward/period', async () => {
mockRepo.getListingVolumeByWard.mockResolvedValue(null);
const query = new GetListingVolumeWardQuery('Phường Không Tồn Tại', '2020-Q1');
await expect(handler.execute(query)).rejects.toThrow(NotFoundException);
});
it('supports monthly period format', async () => {
const volume: ListingVolumeWardResult = {
ward: 'Phường 12',
district: 'Quận Bình Thạnh',
city: 'Hồ Chí Minh',
period: '2026-03',
totalListings: 22,
avgPriceM2: 65_000_000,
medianPrice: '3200000000',
};
mockRepo.getListingVolumeByWard.mockResolvedValue(volume);
const query = new GetListingVolumeWardQuery('Phường 12', '2026-03');
const result = await handler.execute(query);
expect(result.period).toBe('2026-03');
expect(result.totalListings).toBe(22);
});
});

View File

@@ -15,13 +15,11 @@ describe('GetHeatmapHandler', () => {
update: vi.fn(),
getMarketReport: vi.fn(),
getHeatmap: vi.fn(),
getHeatmapWard: vi.fn(),
getListingVolumeByWard: vi.fn(),
getPriceTrend: vi.fn(),
getDistrictStats: vi.fn(),
};
const mockCache = { getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()) } as unknown as CacheService;
handler = new GetHeatmapHandler(mockRepo as any, mockCache, { log: vi.fn(), error: vi.fn(), warn: vi.fn() } as any);
handler = new GetHeatmapHandler(mockRepo as any, mockCache);
});
it('returns heatmap data for a city and period', async () => {
@@ -36,7 +34,6 @@ describe('GetHeatmapHandler', () => {
expect(result.city).toBe('Hồ Chí Minh');
expect(result.period).toBe('2026-Q1');
expect(result.level).toBe('district');
expect(result.dataPoints).toEqual(dataPoints);
expect(mockRepo.getHeatmap).toHaveBeenCalledWith('Hồ Chí Minh', '2026-Q1');
});

View File

@@ -1,136 +0,0 @@
import { InternalServerErrorException } from '@nestjs/common';
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 { GetMarketSnapshotQuery } from '../queries/get-market-snapshot/get-market-snapshot.query';
describe('GetMarketSnapshotHandler', () => {
let handler: GetMarketSnapshotHandler;
let mockPrisma: Record<string, any>;
let mockCache: { getOrSet: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockPrisma = {
listing: {
aggregate: vi.fn(),
count: vi.fn(),
},
$queryRaw: vi.fn(),
};
mockCache = {
getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()),
};
const mockLogger = {
log: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
};
handler = new GetMarketSnapshotHandler(
mockPrisma as unknown as PrismaService,
mockCache as unknown as CacheService,
mockLogger as any,
);
});
it('returns market snapshot with all fields', async () => {
mockPrisma.listing.aggregate.mockResolvedValue({
_count: 12345,
_avg: { priceVND: 4500000000n, pricePerM2: 65000000 },
});
mockPrisma.$queryRaw
.mockResolvedValueOnce([{ median: 3800000000n }]) // median
.mockResolvedValueOnce([{ avg_days: 42.3 }]) // days on market
.mockResolvedValueOnce([{ avg_price: 4400000000 }]) // 1d ago avg
.mockResolvedValueOnce([{ avg_price: 4550000000 }]) // 7d ago avg
.mockResolvedValueOnce([{ avg_price: 4380000000 }]); // 30d ago avg
mockPrisma.listing.count.mockResolvedValue(178);
const query = new GetMarketSnapshotQuery('HCMC', 'APARTMENT');
const result = await handler.execute(query);
expect(result.city).toBe('HCMC');
expect(result.propertyType).toBe('APARTMENT');
expect(result.activeCount).toBe(12345);
expect(result.avgPrice).toBe(4500000000);
expect(result.medianPrice).toBe(3800000000);
expect(result.avgPricePerM2).toBe(65000000);
expect(result.daysOnMarket).toBe(42);
expect(result.newListings24h).toBe(178);
expect(result.priceChangePct).toBeDefined();
expect(typeof result.priceChangePct.d1).toBe('number');
expect(typeof result.priceChangePct.d7).toBe('number');
expect(typeof result.priceChangePct.d30).toBe('number');
});
it('returns snapshot without propertyType filter', async () => {
mockPrisma.listing.aggregate.mockResolvedValue({
_count: 500,
_avg: { priceVND: 3000000000n, pricePerM2: 50000000 },
});
mockPrisma.$queryRaw
.mockResolvedValueOnce([{ median: 2500000000n }])
.mockResolvedValueOnce([{ avg_days: 30 }])
.mockResolvedValueOnce([{ avg_price: 2900000000 }])
.mockResolvedValueOnce([{ avg_price: 3100000000 }])
.mockResolvedValueOnce([{ avg_price: 2800000000 }]);
mockPrisma.listing.count.mockResolvedValue(50);
const query = new GetMarketSnapshotQuery('HCMC');
const result = await handler.execute(query);
expect(result.city).toBe('HCMC');
expect(result.propertyType).toBeUndefined();
expect(result.activeCount).toBe(500);
});
it('handles empty data gracefully', async () => {
mockPrisma.listing.aggregate.mockResolvedValue({
_count: 0,
_avg: { priceVND: null, pricePerM2: null },
});
mockPrisma.$queryRaw
.mockResolvedValueOnce([{ median: null }])
.mockResolvedValueOnce([{ avg_days: null }])
.mockResolvedValueOnce([{ avg_price: null }])
.mockResolvedValueOnce([{ avg_price: null }])
.mockResolvedValueOnce([{ avg_price: null }]);
mockPrisma.listing.count.mockResolvedValue(0);
const query = new GetMarketSnapshotQuery('Hà Nội');
const result = await handler.execute(query);
expect(result.activeCount).toBe(0);
expect(result.avgPrice).toBe(0);
expect(result.medianPrice).toBe(0);
expect(result.avgPricePerM2).toBe(0);
expect(result.daysOnMarket).toBe(0);
expect(result.newListings24h).toBe(0);
expect(result.priceChangePct).toEqual({ d1: 0, d7: 0, d30: 0 });
});
it('uses cache with correct key', async () => {
mockPrisma.listing.aggregate.mockResolvedValue({
_count: 1,
_avg: { priceVND: 1000000000n, pricePerM2: 50000000 },
});
mockPrisma.$queryRaw.mockResolvedValue([{ median: null, avg_days: null, avg_price: null }]);
mockPrisma.listing.count.mockResolvedValue(0);
const query = new GetMarketSnapshotQuery('HCMC', 'APARTMENT');
await handler.execute(query);
expect(mockCache.getOrSet).toHaveBeenCalledWith(
expect.stringContaining('market_snapshot'),
expect.any(Function),
300,
'market_snapshot',
);
});
it('throws InternalServerErrorException on unexpected error', async () => {
mockCache.getOrSet.mockRejectedValue(new Error('DB down'));
const query = new GetMarketSnapshotQuery('HCMC');
await expect(handler.execute(query)).rejects.toThrow(InternalServerErrorException);
});
});

View File

@@ -1,4 +1,3 @@
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 { GetNeighborhoodScoreQuery } from '../queries/get-neighborhood-score/get-neighborhood-score.query';
@@ -20,21 +19,13 @@ const sampleScore: NeighborhoodScoreResult = {
describe('GetNeighborhoodScoreHandler', () => {
let handler: GetNeighborhoodScoreHandler;
let mockService: { [K in keyof INeighborhoodScoreService]: ReturnType<typeof vi.fn> };
let mockCache: { getOrSet: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockService = {
getScore: vi.fn(),
calculateAndSave: vi.fn(),
};
// Bypass cache: call the loader directly
mockCache = {
getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()),
};
handler = new GetNeighborhoodScoreHandler(
mockService as any,
mockCache as unknown as CacheService,
);
handler = new GetNeighborhoodScoreHandler(mockService as any);
});
it('returns cached score when available', async () => {
@@ -57,17 +48,4 @@ describe('GetNeighborhoodScoreHandler', () => {
expect(mockService.getScore).toHaveBeenCalledWith('Quận 2', 'Hồ Chí Minh');
expect(mockService.calculateAndSave).toHaveBeenCalledWith('Quận 2', 'Hồ Chí Minh');
});
it('uses CacheService.getOrSet with 24h TTL', async () => {
mockService.getScore.mockResolvedValue(sampleScore);
await handler.execute(new GetNeighborhoodScoreQuery('Quận 1', 'Hồ Chí Minh'));
expect(mockCache.getOrSet).toHaveBeenCalledWith(
expect.stringContaining('neighborhood_score'),
expect.any(Function),
86400,
'neighborhood-score',
);
});
});

View File

@@ -1,107 +0,0 @@
import { type CacheService, type LoggerService } from '@modules/shared';
import { GetPriceMoversHandler } from '../queries/get-price-movers/get-price-movers.handler';
import { GetPriceMoversQuery } from '../queries/get-price-movers/get-price-movers.query';
describe('GetPriceMoversHandler', () => {
let handler: GetPriceMoversHandler;
let mockPrisma: { $queryRaw: ReturnType<typeof vi.fn> };
let mockCache: Partial<CacheService>;
let mockLogger: Partial<LoggerService>;
beforeEach(() => {
mockPrisma = {
$queryRaw: vi.fn(),
};
mockCache = {
getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()),
} as unknown as Partial<CacheService>;
mockLogger = { error: vi.fn(), warn: vi.fn(), log: vi.fn() } as unknown as Partial<LoggerService>;
handler = new GetPriceMoversHandler(
mockPrisma as any,
mockCache as CacheService,
mockLogger as LoggerService,
);
});
it('returns top price gainers sorted by changePct descending', async () => {
mockPrisma.$queryRaw.mockResolvedValue([
{ district: 'Quận 1', current_avg: 5_000_000_000, previous_avg: 4_000_000_000, sample_size: BigInt(15) },
{ district: 'Quận 7', current_avg: 3_000_000_000, previous_avg: 2_500_000_000, sample_size: BigInt(20) },
{ district: 'Bình Thạnh', current_avg: 2_000_000_000, previous_avg: 2_200_000_000, sample_size: BigInt(12) },
]);
const query = new GetPriceMoversQuery('up', '7d', 5, 'district');
const result = await handler.execute(query);
expect(result.direction).toBe('up');
expect(result.period).toBe('7d');
expect(result.movers.length).toBe(2); // Only positive changes
// Quận 1: +25%, Quận 7: +20%
expect(result.movers[0].districtId).toBe('Quận 1');
expect(result.movers[0].changePct).toBe(25);
expect(result.movers[1].districtId).toBe('Quận 7');
expect(result.movers[1].changePct).toBe(20);
});
it('returns top price losers sorted by changePct ascending', async () => {
mockPrisma.$queryRaw.mockResolvedValue([
{ district: 'Quận 1', current_avg: 5_000_000_000, previous_avg: 4_000_000_000, sample_size: BigInt(15) },
{ district: 'Bình Thạnh', current_avg: 2_000_000_000, previous_avg: 2_200_000_000, sample_size: BigInt(12) },
{ district: 'Thủ Đức', current_avg: 1_800_000_000, previous_avg: 2_100_000_000, sample_size: BigInt(18) },
]);
const query = new GetPriceMoversQuery('down', '7d', 5, 'district');
const result = await handler.execute(query);
expect(result.direction).toBe('down');
expect(result.movers.length).toBe(2); // Only negative changes
// Thủ Đức: -14.29%, Bình Thạnh: -9.09%
expect(result.movers[0].districtId).toBe('Thủ Đức');
expect(result.movers[1].districtId).toBe('Bình Thạnh');
expect(result.movers[0].changePct).toBeLessThan(result.movers[1].changePct);
});
it('respects the limit parameter', async () => {
mockPrisma.$queryRaw.mockResolvedValue([
{ district: 'A', current_avg: 200, previous_avg: 100, sample_size: BigInt(10) },
{ district: 'B', current_avg: 180, previous_avg: 100, sample_size: BigInt(10) },
{ district: 'C', current_avg: 160, previous_avg: 100, sample_size: BigInt(10) },
]);
const query = new GetPriceMoversQuery('up', '7d', 2, 'district');
const result = await handler.execute(query);
expect(result.movers.length).toBe(2);
expect(result.limit).toBe(2);
});
it('returns empty movers when no data', async () => {
mockPrisma.$queryRaw.mockResolvedValue([]);
const query = new GetPriceMoversQuery('up', '7d', 5, 'district');
const result = await handler.execute(query);
expect(result.movers).toEqual([]);
});
it('rounds changePct to two decimal places', async () => {
mockPrisma.$queryRaw.mockResolvedValue([
{ district: 'Quận 1', current_avg: 3_333_333, previous_avg: 3_000_000, sample_size: BigInt(15) },
]);
const query = new GetPriceMoversQuery('up', '7d', 5, 'district');
const result = await handler.execute(query);
expect(result.movers[0].changePct).toBe(11.11);
});
it('throws InternalServerErrorException on unexpected errors', async () => {
mockPrisma.$queryRaw.mockRejectedValue(new Error('DB connection lost'));
const query = new GetPriceMoversQuery('up', '7d', 5, 'district');
await expect(handler.execute(query)).rejects.toThrow(
'Không thể truy vấn biến động giá. Vui lòng thử lại sau.',
);
});
});

View File

@@ -1,119 +0,0 @@
import { type CacheService, type LoggerService } from '@modules/shared';
import { GetTrendingAreasHandler } from '../queries/get-trending-areas/get-trending-areas.handler';
import { GetTrendingAreasQuery } from '../queries/get-trending-areas/get-trending-areas.query';
describe('GetTrendingAreasHandler', () => {
let handler: GetTrendingAreasHandler;
let mockPrisma: { $queryRaw: ReturnType<typeof vi.fn>; marketIndex: { findMany: ReturnType<typeof vi.fn> } };
let mockCache: Partial<CacheService>;
let mockLogger: Partial<LoggerService>;
beforeEach(() => {
mockPrisma = {
$queryRaw: vi.fn(),
marketIndex: {
findMany: vi.fn(),
},
};
// Bypass @Cacheable decorator by making CacheService.getOrSet call the loader directly
mockCache = {
getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()),
} as unknown as Partial<CacheService>;
mockLogger = { error: vi.fn(), warn: vi.fn(), log: vi.fn() } as unknown as Partial<LoggerService>;
handler = new GetTrendingAreasHandler(
mockPrisma as any,
mockCache as CacheService,
mockLogger as LoggerService,
);
});
it('returns top trending districts sorted by score', async () => {
mockPrisma.$queryRaw.mockResolvedValue([
{ district: 'Quận 1', new_listings: BigInt(10), inquiries: BigInt(50), views: BigInt(200) },
{ district: 'Quận 7', new_listings: BigInt(20), inquiries: BigInt(30), views: BigInt(400) },
{ district: 'Bình Thạnh', new_listings: BigInt(5), inquiries: BigInt(5), views: BigInt(50) },
]);
mockPrisma.marketIndex.findMany.mockResolvedValue([
{ district: 'Quận 1', yoyChange: 0.12 },
{ district: 'Quận 7', yoyChange: 0.05 },
]);
const query = new GetTrendingAreasQuery(7, 10, 'district');
const result = await handler.execute(query);
expect(result.period).toBe(7);
expect(result.level).toBe('district');
expect(result.areas.length).toBe(3);
// Quận 1 score = 50*0.6 + 200*0.3 + 10*0.1 = 30 + 60 + 1 = 91
// Quận 7 score = 30*0.6 + 400*0.3 + 20*0.1 = 18 + 120 + 2 = 140
// Bình Thạnh score = 5*0.6 + 50*0.3 + 5*0.1 = 3 + 15 + 0.5 = 18.5
// Expected order: Quận 7 (1st), Quận 1 (2nd), Bình Thạnh (3rd)
expect(result.areas[0].districtId).toBe('Quận 7');
expect(result.areas[0].scoreRank).toBe(1);
expect(result.areas[1].districtId).toBe('Quận 1');
expect(result.areas[2].districtId).toBe('Bình Thạnh');
});
it('respects the limit parameter', async () => {
mockPrisma.$queryRaw.mockResolvedValue([
{ district: 'A', new_listings: BigInt(1), inquiries: BigInt(10), views: BigInt(100) },
{ district: 'B', new_listings: BigInt(1), inquiries: BigInt(8), views: BigInt(80) },
{ district: 'C', new_listings: BigInt(1), inquiries: BigInt(6), views: BigInt(60) },
]);
mockPrisma.marketIndex.findMany.mockResolvedValue([]);
const query = new GetTrendingAreasQuery(7, 2, 'district');
const result = await handler.execute(query);
expect(result.areas.length).toBe(2);
expect(result.limit).toBe(2);
});
it('returns empty areas when no active listings in window', async () => {
mockPrisma.$queryRaw.mockResolvedValue([]);
mockPrisma.marketIndex.findMany.mockResolvedValue([]);
const query = new GetTrendingAreasQuery(7, 10, 'district');
const result = await handler.execute(query);
expect(result.areas).toEqual([]);
expect(mockPrisma.marketIndex.findMany).not.toHaveBeenCalled();
});
it('attaches yoyChange from market index as priceChangePct', async () => {
mockPrisma.$queryRaw.mockResolvedValue([
{ district: 'Quận 1', new_listings: BigInt(5), inquiries: BigInt(20), views: BigInt(100) },
]);
mockPrisma.marketIndex.findMany.mockResolvedValue([
{ district: 'Quận 1', yoyChange: 0.08 },
]);
const query = new GetTrendingAreasQuery(14, 10, 'district');
const result = await handler.execute(query);
expect(result.areas[0].priceChangePct).toBe(0.08);
});
it('sets priceChangePct to null when market index data is missing', async () => {
mockPrisma.$queryRaw.mockResolvedValue([
{ district: 'Huyện Củ Chi', new_listings: BigInt(3), inquiries: BigInt(5), views: BigInt(40) },
]);
mockPrisma.marketIndex.findMany.mockResolvedValue([]);
const query = new GetTrendingAreasQuery(7, 10, 'district');
const result = await handler.execute(query);
expect(result.areas[0].priceChangePct).toBeNull();
});
it('throws InternalServerErrorException on unexpected errors', async () => {
mockPrisma.$queryRaw.mockRejectedValue(new Error('DB connection lost'));
const query = new GetTrendingAreasQuery(7, 10, 'district');
await expect(handler.execute(query)).rejects.toThrow(
'Không thể truy vấn khu vực xu hướng. Vui lòng thử lại sau.',
);
});
});

View File

@@ -1,156 +0,0 @@
import {
RefreshMaterializedViewCronService,
} from '../../infrastructure/services/refresh-materialized-view-cron.service';
function createService(envViews?: string) {
const mockPrisma = { $executeRawUnsafe: vi.fn().mockResolvedValue(undefined) };
const redisClient = {
set: vi.fn().mockResolvedValue('OK'),
del: vi.fn().mockResolvedValue(1),
};
const mockRedis = {
isAvailable: vi.fn().mockReturnValue(true),
getClient: () => redisClient,
};
const mockLogger = {
log: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const configMap: Record<string, string | undefined> = {
MATVIEW_REFRESH_VIEWS: envViews,
};
const mockConfig = { get: vi.fn((key: string) => configMap[key]) };
const mockRefreshCounter = { inc: vi.fn() };
const mockRefreshDuration = { observe: vi.fn() };
const mockRefreshErrors = { inc: vi.fn() };
const service = new RefreshMaterializedViewCronService(
mockPrisma as any,
mockRedis as any,
mockLogger as any,
mockConfig as any,
mockRefreshCounter as any,
mockRefreshDuration as any,
mockRefreshErrors as any,
);
return {
service,
mockPrisma,
mockRedis,
redisClient,
mockLogger,
mockRefreshCounter,
mockRefreshDuration,
mockRefreshErrors,
};
}
const VIEW_CONFIG = JSON.stringify([
{ viewName: 'mv_test', cron: '*/5 * * * *', expectedDurationSeconds: 30 },
]);
describe('RefreshMaterializedViewCronService', () => {
it('refreshes a configured view and records success metrics', async () => {
const { service, mockPrisma, mockRefreshCounter, mockRefreshDuration } =
createService(VIEW_CONFIG);
const result = await service.tryRefresh({
viewName: 'mv_test',
cron: '*/5 * * * *',
expectedDurationSeconds: 30,
});
expect(result).toBe(true);
expect(mockPrisma.$executeRawUnsafe).toHaveBeenCalledWith(
'REFRESH MATERIALIZED VIEW CONCURRENTLY "mv_test"',
);
expect(mockRefreshCounter.inc).toHaveBeenCalledWith({
view: 'mv_test',
status: 'success',
});
expect(mockRefreshDuration.observe).toHaveBeenCalledWith(
{ view: 'mv_test' },
expect.any(Number),
);
});
it('skips refresh when Redis lock is already held', async () => {
const { service, mockPrisma, redisClient } = createService(VIEW_CONFIG);
redisClient.set.mockResolvedValue(null); // NX fails
const result = await service.tryRefresh({
viewName: 'mv_test',
cron: '*/5 * * * *',
expectedDurationSeconds: 30,
});
expect(result).toBe(false);
expect(mockPrisma.$executeRawUnsafe).not.toHaveBeenCalled();
});
it('records error metric on SQL failure', async () => {
const { service, mockPrisma, mockRefreshErrors } = createService(VIEW_CONFIG);
mockPrisma.$executeRawUnsafe.mockRejectedValue(new Error('relation does not exist'));
await service.tryRefresh({
viewName: 'mv_test',
cron: '*/5 * * * *',
expectedDurationSeconds: 30,
});
expect(mockRefreshErrors.inc).toHaveBeenCalledWith({
view: 'mv_test',
reason: 'query',
});
});
it('degrades open when Redis is unavailable (no mutex)', async () => {
const { service, mockPrisma, mockRedis } = createService(VIEW_CONFIG);
mockRedis.isAvailable.mockReturnValue(false);
const result = await service.tryRefresh({
viewName: 'mv_test',
cron: '*/5 * * * *',
expectedDurationSeconds: 30,
});
expect(result).toBe(true);
expect(mockPrisma.$executeRawUnsafe).toHaveBeenCalled();
});
it('tick() is a no-op when no views are configured (Phase 0 default)', async () => {
const { service, mockPrisma } = createService(undefined);
await service.tick();
expect(mockPrisma.$executeRawUnsafe).not.toHaveBeenCalled();
});
it('releases lock even when refresh fails', async () => {
const { service, mockPrisma, redisClient } = createService(VIEW_CONFIG);
mockPrisma.$executeRawUnsafe.mockRejectedValue(new Error('boom'));
await service.tryRefresh({
viewName: 'mv_test',
cron: '*/5 * * * *',
expectedDurationSeconds: 30,
});
expect(redisClient.del).toHaveBeenCalledWith('matview:lock:mv_test');
});
it('refreshView() throws for unknown view names', async () => {
const { service } = createService(VIEW_CONFIG);
await expect(service.refreshView('nonexistent')).rejects.toThrow(
'Unknown materialized view: nonexistent',
);
});
});

View File

@@ -1,5 +1,6 @@
import { InternalServerErrorException } from '@nestjs/common';
import { type CacheService, type PrismaService, DomainException } from '@modules/shared';
import { type CacheService, type PrismaService } from '@modules/shared';
import { DomainException } from '@modules/shared';
import { type IAVMService, type ValuationResult } from '../../domain/services/avm-service';
import { ValuationComparisonHandler } from '../queries/valuation-comparison/valuation-comparison.handler';
import { ValuationComparisonQuery } from '../queries/valuation-comparison/valuation-comparison.query';

View File

@@ -1,118 +0,0 @@
import { InternalServerErrorException } from '@nestjs/common';
import { DomainException, NotFoundException } from '@modules/shared';
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
import { ValuationEntity } from '../../domain/entities/valuation.entity';
import { type IValuationRepository } from '../../domain/repositories/valuation.repository';
import { ValuationExplanationHandler } from '../queries/valuation-explanation/valuation-explanation.handler';
import { ValuationExplanationQuery } from '../queries/valuation-explanation/valuation-explanation.query';
describe('ValuationExplanationHandler', () => {
let handler: ValuationExplanationHandler;
let mockRepo: { [K in keyof IValuationRepository]: ReturnType<typeof vi.fn> };
let mockLogger: { error: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockRepo = {
findById: vi.fn(),
findByPropertyId: vi.fn(),
findLatestByPropertyId: vi.fn(),
save: vi.fn(),
};
mockLogger = { error: vi.fn() };
const mockCache = {
getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()),
} as unknown as CacheService;
handler = new ValuationExplanationHandler(
mockRepo as any,
mockCache,
mockLogger as any,
);
});
it('returns explanation with top drivers from drivers array', async () => {
const entity = new ValuationEntity(
'val-1',
{
propertyId: 'prop-1',
estimatedPrice: 5_000_000_000n,
confidence: 0.87,
pricePerM2: 75_000_000,
comparables: [
{ propertyId: 'c1', address: 'A', district: 'D1', priceVND: '5000000000', pricePerM2: 75000000, areaM2: 60, propertyType: 'APARTMENT', distanceMeters: 100, soldAt: '2026-01-01' },
],
features: {
drivers: [
{ feature: 'location', importance: 0.45 },
{ feature: 'area', importance: -0.22 },
{ feature: 'year_built', importance: 0.12 },
],
},
modelVersion: 'avm-v2.0',
},
new Date('2026-04-15T10:00:00Z'),
);
mockRepo.findById.mockResolvedValue(entity);
const result = await handler.execute(new ValuationExplanationQuery('val-1'));
expect(result.valuationId).toBe('val-1');
expect(result.propertyId).toBe('prop-1');
expect(result.modelVersion).toBe('avm-v2.0');
expect(result.estimatedPrice).toBe('5000000000');
expect(result.topDrivers).toHaveLength(3);
// Sorted by |importance| descending
expect(result.topDrivers[0]!.feature).toBe('location');
expect(result.topDrivers[1]!.feature).toBe('area');
expect(result.comparables).toHaveLength(1);
expect(result.confidenceExplanation).toContain('cao');
expect(result.valuedAt).toBe('2026-04-15T10:00:00.000Z');
});
it('falls back to object-of-numbers feature importances', async () => {
const entity = new ValuationEntity(
'val-2',
{
propertyId: 'prop-2',
estimatedPrice: 3_000_000_000n,
confidence: 0.55,
pricePerM2: 50_000_000,
comparables: [],
features: { location: 0.6, area: 0.2, foo: 'not-number' },
modelVersion: 'avm-v1.0',
},
new Date('2026-03-01T00:00:00Z'),
);
mockRepo.findById.mockResolvedValue(entity);
const result = await handler.execute(new ValuationExplanationQuery('val-2'));
expect(result.topDrivers.map((d) => d.feature)).toEqual(['location', 'area']);
expect(result.comparables).toEqual([]);
});
it('throws NotFoundException when valuation does not exist', async () => {
mockRepo.findById.mockResolvedValue(null);
await expect(
handler.execute(new ValuationExplanationQuery('missing')),
).rejects.toThrow(NotFoundException);
});
it('re-throws DomainException directly', async () => {
const domainError = new DomainException('NOT_FOUND' as any, 'Valuation not found');
mockRepo.findById.mockRejectedValue(domainError);
await expect(
handler.execute(new ValuationExplanationQuery('v-err')),
).rejects.toThrow(DomainException);
});
it('wraps unexpected errors in InternalServerErrorException', async () => {
mockRepo.findById.mockRejectedValue(new Error('DB down'));
await expect(
handler.execute(new ValuationExplanationQuery('v-err')),
).rejects.toThrow(InternalServerErrorException);
expect(mockLogger.error).toHaveBeenCalled();
});
});

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