Compare commits
1 Commits
master
...
99385d8263
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99385d8263 |
@@ -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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
61
.env.example
61
.env.example
@@ -32,19 +32,6 @@ REDIS_PORT=6379
|
|||||||
REDIS_PASSWORD=CHANGE_ME_IN_PRODUCTION
|
REDIS_PASSWORD=CHANGE_ME_IN_PRODUCTION
|
||||||
REDIS_URL=redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}
|
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
|
# Typesense
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -91,15 +78,6 @@ JWT_EXPIRES_IN=15m
|
|||||||
JWT_REFRESH_SECRET=<generate with: openssl rand -base64 48>
|
JWT_REFRESH_SECRET=<generate with: openssl rand -base64 48>
|
||||||
JWT_REFRESH_EXPIRES_IN=7d
|
JWT_REFRESH_EXPIRES_IN=7d
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# Seed / E2E Accounts
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# Required when running `pnpm db:seed`. Use a local/test-only value.
|
|
||||||
# Do not reuse this password for any real production admin account.
|
|
||||||
SEED_DEFAULT_PASSWORD=
|
|
||||||
BCRYPT_ROUNDS=12
|
|
||||||
E2E_ADMIN_PHONE=0876677771
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# OAuth Providers
|
# OAuth Providers
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -119,19 +97,11 @@ FRONTEND_URL=http://localhost:3000
|
|||||||
NEXT_PUBLIC_API_URL=http://localhost:3000
|
NEXT_PUBLIC_API_URL=http://localhost:3000
|
||||||
WEB_PORT=3001
|
WEB_PORT=3001
|
||||||
|
|
||||||
# Demo accounts must stay disabled in production. To enable in a local demo,
|
|
||||||
# provide a JSON array of {phone,name,role,badgeClass} and a temporary password.
|
|
||||||
NEXT_PUBLIC_ENABLE_DEMO_ACCOUNTS=false
|
|
||||||
NEXT_PUBLIC_DEMO_PASSWORD=
|
|
||||||
NEXT_PUBLIC_DEMO_ACCOUNTS=
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# AI Service (Python/FastAPI)
|
# AI Service (Python/FastAPI)
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
AI_SERVICE_PORT=8000
|
AI_SERVICE_PORT=8000
|
||||||
AI_SERVICE_URL=http://localhost:8000
|
AI_SERVICE_URL=http://localhost:8000
|
||||||
AI_SERVICE_API_KEY=<optional-in-dev-required-in-prod>
|
|
||||||
AI_CORS_ORIGINS=http://localhost:3000,http://localhost:3001
|
|
||||||
CLAUDE_API_KEY=
|
CLAUDE_API_KEY=
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -141,47 +111,23 @@ NEXT_PUBLIC_MAPBOX_TOKEN=
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Payment Gateways (VNPay, MoMo, ZaloPay)
|
# Payment Gateways (VNPay, MoMo, ZaloPay)
|
||||||
# Leave empty if not using payment features.
|
# 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.
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
# 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_TMN_CODE=
|
||||||
VNPAY_HASH_SECRET=
|
VNPAY_HASH_SECRET=
|
||||||
VNPAY_BASE_URL=https://sandbox.vnpayment.vn/paymentv2/vpcpay.html
|
VNPAY_BASE_URL=https://sandbox.vnpayment.vn/paymentv2/vpcpay.html
|
||||||
VNPAY_API_URL=https://sandbox.vnpayment.vn/merchant_webapi/api/transaction
|
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_PARTNER_CODE=
|
||||||
MOMO_ACCESS_KEY=
|
MOMO_ACCESS_KEY=
|
||||||
MOMO_SECRET_KEY=
|
MOMO_SECRET_KEY=
|
||||||
MOMO_ENDPOINT=https://test-payment.momo.vn/v2/gateway/api
|
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_APP_ID=
|
||||||
ZALOPAY_KEY1=
|
ZALOPAY_KEY1=
|
||||||
ZALOPAY_KEY2=
|
ZALOPAY_KEY2=
|
||||||
ZALOPAY_ENDPOINT=https://sb-openapi.zalopay.vn/v2
|
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_ACCOUNT_NUMBER=
|
||||||
BANK_TRANSFER_BANK_NAME=
|
BANK_TRANSFER_BANK_NAME=
|
||||||
BANK_TRANSFER_ACCOUNT_HOLDER=
|
BANK_TRANSFER_ACCOUNT_HOLDER=
|
||||||
@@ -238,10 +184,7 @@ SENTRY_PROJECT=
|
|||||||
# Must be exactly 64 hex characters (32 bytes).
|
# Must be exactly 64 hex characters (32 bytes).
|
||||||
# openssl rand -hex 32
|
# openssl rand -hex 32
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
FIELD_ENCRYPTION_KEY=<generate with: openssl rand -hex 32>
|
KYC_ENCRYPTION_KEY=<generate with: openssl rand -hex 32>
|
||||||
FIELD_ENCRYPTION_KEY_VERSION=1
|
|
||||||
# Backward-compatible fallback accepted by the API; prefer FIELD_ENCRYPTION_KEY.
|
|
||||||
KYC_ENCRYPTION_KEY=
|
|
||||||
KYC_ENCRYPTION_KEY_VERSION=1
|
KYC_ENCRYPTION_KEY_VERSION=1
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -51,10 +51,6 @@ CORS_ORIGINS=http://localhost:3010,http://localhost:3000
|
|||||||
# Bcrypt (fast rounds for test — production uses 12+)
|
# Bcrypt (fast rounds for test — production uses 12+)
|
||||||
BCRYPT_ROUNDS=4
|
BCRYPT_ROUNDS=4
|
||||||
|
|
||||||
# Seeded admin used by E2E happy-path admin flows
|
|
||||||
SEED_DEFAULT_PASSWORD=Test@1234!
|
|
||||||
E2E_ADMIN_PHONE=0876677771
|
|
||||||
|
|
||||||
# OAuth (test stubs)
|
# OAuth (test stubs)
|
||||||
GOOGLE_CLIENT_ID=test-google-client-id
|
GOOGLE_CLIENT_ID=test-google-client-id
|
||||||
GOOGLE_CLIENT_SECRET=test-google-client-secret
|
GOOGLE_CLIENT_SECRET=test-google-client-secret
|
||||||
@@ -74,8 +70,3 @@ MOMO_SECRET_KEY=TEST_MOMO_SECRET_KEY
|
|||||||
ZALOPAY_APP_ID=TEST_ZALOPAY_APP
|
ZALOPAY_APP_ID=TEST_ZALOPAY_APP
|
||||||
ZALOPAY_KEY1=TEST_ZALOPAY_KEY1
|
ZALOPAY_KEY1=TEST_ZALOPAY_KEY1
|
||||||
ZALOPAY_KEY2=TEST_ZALOPAY_KEY2
|
ZALOPAY_KEY2=TEST_ZALOPAY_KEY2
|
||||||
BANK_TRANSFER_ACCOUNT_NUMBER=0123456789
|
|
||||||
BANK_TRANSFER_BANK_NAME=Vietcombank
|
|
||||||
BANK_TRANSFER_ACCOUNT_HOLDER=CONG_TY_GOODGO
|
|
||||||
BANK_TRANSFER_WEBHOOK_SECRET=test-bank-transfer-webhook-secret-minimum-32-chars
|
|
||||||
BANK_TRANSFER_INSTRUCTIONS_URL=http://localhost:3010/thanh-toan/chuyen-khoan
|
|
||||||
|
|||||||
158
.github/workflows/ci.yml
vendored
158
.github/workflows/ci.yml
vendored
@@ -70,89 +70,83 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: pnpm 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:
|
e2e:
|
||||||
name: E2E Tests
|
name: E2E Tests
|
||||||
needs: ci
|
needs: ci
|
||||||
runs-on: ubuntu-latest
|
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:
|
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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -170,12 +164,6 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Load E2E environment
|
|
||||||
run: awk 'NF && $1 !~ /^#/' .env.test >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Start CI service stack
|
|
||||||
run: docker compose --env-file .env.ci -f docker-compose.ci.yml up -d --wait
|
|
||||||
|
|
||||||
- name: Cache Playwright browsers
|
- name: Cache Playwright browsers
|
||||||
id: playwright-cache
|
id: playwright-cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -218,7 +206,3 @@ jobs:
|
|||||||
name: playwright-traces
|
name: playwright-traces
|
||||||
path: test-results/
|
path: test-results/
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: Stop CI service stack
|
|
||||||
if: always()
|
|
||||||
run: docker compose --env-file .env.ci -f docker-compose.ci.yml down -v
|
|
||||||
|
|||||||
61
.github/workflows/codeql.yml
vendored
Normal file
61
.github/workflows/codeql.yml
vendored
Normal 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
|
||||||
187
.github/workflows/deploy.yml
vendored
187
.github/workflows/deploy.yml
vendored
@@ -23,53 +23,6 @@ env:
|
|||||||
REGISTRY_URL: ghcr.io/${{ github.repository_owner }}
|
REGISTRY_URL: ghcr.io/${{ github.repository_owner }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy-config:
|
|
||||||
name: Check Deploy Configuration
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
staging_ready: ${{ steps.check.outputs.staging_ready }}
|
|
||||||
production_ready: ${{ steps.check.outputs.production_ready }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check required deploy secrets
|
|
||||||
id: check
|
|
||||||
env:
|
|
||||||
TARGET_ENV: ${{ inputs.environment }}
|
|
||||||
STAGING_HOST: ${{ secrets.STAGING_HOST }}
|
|
||||||
STAGING_USER: ${{ secrets.STAGING_USER }}
|
|
||||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
|
||||||
STAGING_URL: ${{ secrets.STAGING_URL }}
|
|
||||||
STAGING_API_URL: ${{ secrets.STAGING_API_URL }}
|
|
||||||
PRODUCTION_HOST: ${{ secrets.PRODUCTION_HOST }}
|
|
||||||
PRODUCTION_USER: ${{ secrets.PRODUCTION_USER }}
|
|
||||||
PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
|
|
||||||
PRODUCTION_URL: ${{ secrets.PRODUCTION_URL }}
|
|
||||||
PRODUCTION_API_URL: ${{ secrets.PRODUCTION_API_URL }}
|
|
||||||
run: |
|
|
||||||
STAGING_READY=false
|
|
||||||
PRODUCTION_READY=false
|
|
||||||
|
|
||||||
if [ -n "$STAGING_HOST" ] && [ -n "$STAGING_USER" ] && [ -n "$STAGING_SSH_KEY" ] && [ -n "$STAGING_URL" ] && [ -n "$STAGING_API_URL" ]; then
|
|
||||||
STAGING_READY=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$PRODUCTION_HOST" ] && [ -n "$PRODUCTION_USER" ] && [ -n "$PRODUCTION_SSH_KEY" ] && [ -n "$PRODUCTION_URL" ] && [ -n "$PRODUCTION_API_URL" ]; then
|
|
||||||
PRODUCTION_READY=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "staging_ready=$STAGING_READY" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "production_ready=$PRODUCTION_READY" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ "$TARGET_ENV" = "staging" ] && [ "$STAGING_READY" != "true" ]; then
|
|
||||||
echo "Missing required staging deploy secrets; configure STAGING_HOST, STAGING_USER, STAGING_SSH_KEY, STAGING_URL, and STAGING_API_URL."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ "$TARGET_ENV" = "production" ] && [ "$PRODUCTION_READY" != "true" ]; then
|
|
||||||
echo "Missing required production deploy secrets; configure PRODUCTION_HOST, PRODUCTION_USER, PRODUCTION_SSH_KEY, PRODUCTION_URL, and PRODUCTION_API_URL."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
build-api:
|
build-api:
|
||||||
name: Build API Image
|
name: Build API Image
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -201,14 +154,11 @@ jobs:
|
|||||||
|
|
||||||
deploy-staging:
|
deploy-staging:
|
||||||
name: Deploy to Staging
|
name: Deploy to Staging
|
||||||
needs: [deploy-config, build-api, build-web, build-ai]
|
needs: [build-api, build-web, build-ai]
|
||||||
if: >-
|
if: >-
|
||||||
needs.deploy-config.outputs.staging_ready == 'true' &&
|
|
||||||
(
|
|
||||||
github.ref == 'refs/heads/develop' ||
|
github.ref == 'refs/heads/develop' ||
|
||||||
(github.event_name == 'push' && github.ref == 'refs/heads/master') ||
|
(github.event_name == 'push' && github.ref == 'refs/heads/master') ||
|
||||||
(github.event_name == 'workflow_dispatch' && inputs.environment == 'staging')
|
(github.event_name == 'workflow_dispatch' && inputs.environment == 'staging')
|
||||||
)
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: staging
|
environment: staging
|
||||||
|
|
||||||
@@ -271,17 +221,17 @@ jobs:
|
|||||||
[ "\$PREV_WEB" != "none" ] && docker tag "\$PREV_WEB" goodgo-web:rollback 2>/dev/null || true
|
[ "\$PREV_WEB" != "none" ] && docker tag "\$PREV_WEB" goodgo-web:rollback 2>/dev/null || true
|
||||||
[ "\$PREV_AI" != "none" ] && docker tag "\$PREV_AI" goodgo-ai-services:rollback 2>/dev/null || true
|
[ "\$PREV_AI" != "none" ] && docker tag "\$PREV_AI" goodgo-ai-services:rollback 2>/dev/null || true
|
||||||
|
|
||||||
|
# Pull new images
|
||||||
docker compose -f docker-compose.prod.yml pull api web ai-services
|
docker compose -f docker-compose.prod.yml pull api web ai-services
|
||||||
|
|
||||||
# Apply migrations with the newly pulled API image before switching app containers.
|
|
||||||
docker compose -f docker-compose.prod.yml run --rm --no-deps api \
|
|
||||||
npx prisma migrate deploy --schema /app/prisma/schema.prisma
|
|
||||||
|
|
||||||
# Rolling update — zero downtime
|
# Rolling update — zero downtime
|
||||||
docker compose -f docker-compose.prod.yml up -d --no-deps --wait api
|
docker compose -f docker-compose.prod.yml up -d --no-deps --wait api
|
||||||
docker compose -f docker-compose.prod.yml up -d --no-deps --wait web
|
docker compose -f docker-compose.prod.yml up -d --no-deps --wait web
|
||||||
docker compose -f docker-compose.prod.yml up -d --no-deps --wait ai-services
|
docker compose -f docker-compose.prod.yml up -d --no-deps --wait ai-services
|
||||||
|
|
||||||
|
# Run database migrations
|
||||||
|
docker compose -f docker-compose.prod.yml exec -T api npx prisma migrate deploy
|
||||||
|
|
||||||
# NOTE: docker image prune is NOT run here — it runs after smoke tests pass
|
# NOTE: docker image prune is NOT run here — it runs after smoke tests pass
|
||||||
DEPLOY_SCRIPT
|
DEPLOY_SCRIPT
|
||||||
|
|
||||||
@@ -332,61 +282,13 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Run bash smoke tests
|
- name: Run smoke tests
|
||||||
env:
|
env:
|
||||||
STAGING_URL: ${{ secrets.STAGING_URL }}
|
STAGING_URL: ${{ secrets.STAGING_URL }}
|
||||||
run: |
|
run: |
|
||||||
chmod +x scripts/smoke-test.sh
|
chmod +x scripts/smoke-test.sh
|
||||||
./scripts/smoke-test.sh "$STAGING_URL"
|
./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
|
- name: Cleanup old images after successful smoke tests
|
||||||
if: success()
|
if: success()
|
||||||
env:
|
env:
|
||||||
@@ -444,11 +346,8 @@ jobs:
|
|||||||
|
|
||||||
rollback-staging:
|
rollback-staging:
|
||||||
name: Rollback Staging
|
name: Rollback Staging
|
||||||
needs: [deploy-config, deploy-staging, smoke-test-staging]
|
needs: [deploy-staging, smoke-test-staging]
|
||||||
if: >-
|
if: failure()
|
||||||
always() &&
|
|
||||||
needs.deploy-config.outputs.staging_ready == 'true' &&
|
|
||||||
(needs.deploy-staging.result == 'failure' || needs.smoke-test-staging.result == 'failure')
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: staging
|
environment: staging
|
||||||
|
|
||||||
@@ -515,11 +414,8 @@ jobs:
|
|||||||
|
|
||||||
deploy-production:
|
deploy-production:
|
||||||
name: Deploy to Production
|
name: Deploy to Production
|
||||||
needs: [deploy-config, build-api, build-web, build-ai]
|
needs: [build-api, build-web, build-ai]
|
||||||
if: >-
|
if: inputs.environment == 'production'
|
||||||
github.event_name == 'workflow_dispatch' &&
|
|
||||||
inputs.environment == 'production' &&
|
|
||||||
needs.deploy-config.outputs.production_ready == 'true'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: production
|
environment: production
|
||||||
|
|
||||||
@@ -563,15 +459,13 @@ jobs:
|
|||||||
|
|
||||||
docker compose -f docker-compose.prod.yml pull api web ai-services
|
docker compose -f docker-compose.prod.yml pull api web ai-services
|
||||||
|
|
||||||
# Apply migrations with the newly pulled API image before switching app containers.
|
|
||||||
docker compose -f docker-compose.prod.yml run --rm --no-deps api \
|
|
||||||
npx prisma migrate deploy --schema /app/prisma/schema.prisma
|
|
||||||
|
|
||||||
# Rolling update with health checks
|
# Rolling update with health checks
|
||||||
docker compose -f docker-compose.prod.yml up -d --no-deps --wait api
|
docker compose -f docker-compose.prod.yml up -d --no-deps --wait api
|
||||||
docker compose -f docker-compose.prod.yml up -d --no-deps --wait web
|
docker compose -f docker-compose.prod.yml up -d --no-deps --wait web
|
||||||
docker compose -f docker-compose.prod.yml up -d --no-deps --wait ai-services
|
docker compose -f docker-compose.prod.yml up -d --no-deps --wait ai-services
|
||||||
|
|
||||||
|
docker compose -f docker-compose.prod.yml exec -T api npx prisma migrate deploy
|
||||||
|
|
||||||
# NOTE: docker image prune is NOT run here — it runs after smoke tests pass
|
# NOTE: docker image prune is NOT run here — it runs after smoke tests pass
|
||||||
DEPLOY_SCRIPT
|
DEPLOY_SCRIPT
|
||||||
|
|
||||||
@@ -616,61 +510,13 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Run bash smoke tests
|
- name: Run smoke tests
|
||||||
env:
|
env:
|
||||||
PRODUCTION_URL: ${{ secrets.PRODUCTION_URL }}
|
PRODUCTION_URL: ${{ secrets.PRODUCTION_URL }}
|
||||||
run: |
|
run: |
|
||||||
chmod +x scripts/smoke-test.sh
|
chmod +x scripts/smoke-test.sh
|
||||||
./scripts/smoke-test.sh "$PRODUCTION_URL"
|
./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
|
- name: Cleanup old images after successful smoke tests
|
||||||
if: success()
|
if: success()
|
||||||
env:
|
env:
|
||||||
@@ -710,11 +556,8 @@ jobs:
|
|||||||
|
|
||||||
rollback-production:
|
rollback-production:
|
||||||
name: Rollback Production
|
name: Rollback Production
|
||||||
needs: [deploy-config, deploy-production, smoke-test-production]
|
needs: [smoke-test-production]
|
||||||
if: >-
|
if: failure()
|
||||||
always() &&
|
|
||||||
needs.deploy-config.outputs.production_ready == 'true' &&
|
|
||||||
(needs.deploy-production.result == 'failure' || needs.smoke-test-production.result == 'failure')
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: production
|
environment: production
|
||||||
|
|
||||||
|
|||||||
100
.github/workflows/e2e.yml
vendored
100
.github/workflows/e2e.yml
vendored
@@ -14,10 +14,98 @@ jobs:
|
|||||||
e2e:
|
e2e:
|
||||||
name: Playwright E2E
|
name: Playwright E2E
|
||||||
runs-on: ubuntu-latest
|
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:
|
env:
|
||||||
|
DATABASE_URL: postgresql://goodgo:goodgo_test_secret@localhost:5432/goodgo_test
|
||||||
|
REDIS_URL: redis://localhost:6379
|
||||||
|
REDIS_HOST: localhost
|
||||||
|
REDIS_PORT: 6379
|
||||||
|
TYPESENSE_URL: http://localhost:8108
|
||||||
|
TYPESENSE_HOST: localhost
|
||||||
|
TYPESENSE_PORT: 8108
|
||||||
|
TYPESENSE_PROTOCOL: http
|
||||||
|
TYPESENSE_API_KEY: ts_ci_key
|
||||||
|
MINIO_ENDPOINT: localhost
|
||||||
|
MINIO_PORT: 9000
|
||||||
|
MINIO_ACCESS_KEY: ${{ vars.CI_MINIO_ACCESS_KEY || 'ci_minio_user' }}
|
||||||
|
MINIO_SECRET_KEY: ${{ vars.CI_MINIO_SECRET_KEY || 'ci_minio_secret_key_32chars!!' }}
|
||||||
|
MINIO_BUCKET: goodgo-uploads
|
||||||
|
NODE_ENV: test
|
||||||
CI: true
|
CI: true
|
||||||
|
# API and Web ports for Playwright webServer
|
||||||
|
API_PORT: 3001
|
||||||
|
WEB_PORT: 3000
|
||||||
|
API_BASE_URL: http://localhost:3001/api/v1/
|
||||||
|
WEB_BASE_URL: http://localhost:3000
|
||||||
|
NEXT_PUBLIC_API_URL: http://localhost:3001/api/v1
|
||||||
|
JWT_SECRET: e2e-test-jwt-secret-key-minimum-32-chars-long-enough
|
||||||
|
JWT_REFRESH_SECRET: e2e-test-refresh-secret-key-minimum-32-chars-ok
|
||||||
|
JWT_EXPIRES_IN: 15m
|
||||||
|
JWT_REFRESH_EXPIRES_IN: 7d
|
||||||
|
BCRYPT_ROUNDS: 4
|
||||||
|
VNPAY_TMN_CODE: TESTCODE
|
||||||
|
VNPAY_HASH_SECRET: TESTHASHSECRETTESTHASHSECRETTEST
|
||||||
|
VNPAY_URL: https://sandbox.vnpayment.vn/paymentv2/vpcpay.html
|
||||||
|
VNPAY_RETURN_URL: http://localhost:3000/payment/return
|
||||||
|
GOOGLE_CLIENT_ID: test-google-client-id
|
||||||
|
GOOGLE_CLIENT_SECRET: test-google-client-secret
|
||||||
|
GOOGLE_CALLBACK_URL: http://localhost:3001/api/v1/auth/google/callback
|
||||||
|
ZALO_APP_ID: test-zalo-app-id
|
||||||
|
ZALO_APP_SECRET: test-zalo-app-secret
|
||||||
|
ZALO_CALLBACK_URL: http://localhost:3001/api/v1/auth/zalo/callback
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -35,12 +123,6 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Load E2E environment
|
|
||||||
run: awk 'NF && $1 !~ /^#/' .env.test >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Start CI service stack
|
|
||||||
run: docker compose --env-file .env.ci -f docker-compose.ci.yml up -d --wait
|
|
||||||
|
|
||||||
- name: Cache Playwright browsers
|
- name: Cache Playwright browsers
|
||||||
id: playwright-cache
|
id: playwright-cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -83,7 +165,3 @@ jobs:
|
|||||||
name: playwright-traces
|
name: playwright-traces
|
||||||
path: test-results/
|
path: test-results/
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: Stop CI service stack
|
|
||||||
if: always()
|
|
||||||
run: docker compose --env-file .env.ci -f docker-compose.ci.yml down -v
|
|
||||||
|
|||||||
76
.github/workflows/security.yml
vendored
76
.github/workflows/security.yml
vendored
@@ -15,6 +15,7 @@ concurrency:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── Dependency Audit ─────────────────────────────────────────────
|
# ── Dependency Audit ─────────────────────────────────────────────
|
||||||
@@ -95,8 +96,25 @@ jobs:
|
|||||||
cache-from: type=gha,scope=api-scan
|
cache-from: type=gha,scope=api-scan
|
||||||
cache-to: type=gha,mode=max,scope=api-scan
|
cache-to: type=gha,mode=max,scope=api-scan
|
||||||
|
|
||||||
|
- name: Run Trivy vulnerability scanner (API)
|
||||||
|
uses: aquasecurity/trivy-action@0.28.0
|
||||||
|
with:
|
||||||
|
image-ref: "goodgo-api:scan"
|
||||||
|
format: "sarif"
|
||||||
|
output: "trivy-api-results.sarif"
|
||||||
|
severity: "CRITICAL,HIGH"
|
||||||
|
# Ignore unfixed vulns to reduce noise
|
||||||
|
ignore-unfixed: true
|
||||||
|
|
||||||
|
- name: Upload Trivy SARIF (API)
|
||||||
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
sarif_file: "trivy-api-results.sarif"
|
||||||
|
category: "trivy-api"
|
||||||
|
|
||||||
- name: Trivy table output (API)
|
- name: Trivy table output (API)
|
||||||
uses: aquasecurity/trivy-action@v0.36.0
|
uses: aquasecurity/trivy-action@0.28.0
|
||||||
with:
|
with:
|
||||||
image-ref: "goodgo-api:scan"
|
image-ref: "goodgo-api:scan"
|
||||||
format: "table"
|
format: "table"
|
||||||
@@ -126,8 +144,24 @@ jobs:
|
|||||||
cache-from: type=gha,scope=web-scan
|
cache-from: type=gha,scope=web-scan
|
||||||
cache-to: type=gha,mode=max,scope=web-scan
|
cache-to: type=gha,mode=max,scope=web-scan
|
||||||
|
|
||||||
|
- name: Run Trivy vulnerability scanner (Web)
|
||||||
|
uses: aquasecurity/trivy-action@0.28.0
|
||||||
|
with:
|
||||||
|
image-ref: "goodgo-web:scan"
|
||||||
|
format: "sarif"
|
||||||
|
output: "trivy-web-results.sarif"
|
||||||
|
severity: "CRITICAL,HIGH"
|
||||||
|
ignore-unfixed: true
|
||||||
|
|
||||||
|
- name: Upload Trivy SARIF (Web)
|
||||||
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
sarif_file: "trivy-web-results.sarif"
|
||||||
|
category: "trivy-web"
|
||||||
|
|
||||||
- name: Trivy table output (Web)
|
- name: Trivy table output (Web)
|
||||||
uses: aquasecurity/trivy-action@v0.36.0
|
uses: aquasecurity/trivy-action@0.28.0
|
||||||
with:
|
with:
|
||||||
image-ref: "goodgo-web:scan"
|
image-ref: "goodgo-web:scan"
|
||||||
format: "table"
|
format: "table"
|
||||||
@@ -157,8 +191,24 @@ jobs:
|
|||||||
cache-from: type=gha,scope=ai-scan
|
cache-from: type=gha,scope=ai-scan
|
||||||
cache-to: type=gha,mode=max,scope=ai-scan
|
cache-to: type=gha,mode=max,scope=ai-scan
|
||||||
|
|
||||||
|
- name: Run Trivy vulnerability scanner (AI)
|
||||||
|
uses: aquasecurity/trivy-action@0.28.0
|
||||||
|
with:
|
||||||
|
image-ref: "goodgo-ai:scan"
|
||||||
|
format: "sarif"
|
||||||
|
output: "trivy-ai-results.sarif"
|
||||||
|
severity: "CRITICAL,HIGH"
|
||||||
|
ignore-unfixed: true
|
||||||
|
|
||||||
|
- name: Upload Trivy SARIF (AI)
|
||||||
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
sarif_file: "trivy-ai-results.sarif"
|
||||||
|
category: "trivy-ai"
|
||||||
|
|
||||||
- name: Trivy table output (AI)
|
- name: Trivy table output (AI)
|
||||||
uses: aquasecurity/trivy-action@v0.36.0
|
uses: aquasecurity/trivy-action@0.28.0
|
||||||
with:
|
with:
|
||||||
image-ref: "goodgo-ai:scan"
|
image-ref: "goodgo-ai:scan"
|
||||||
format: "table"
|
format: "table"
|
||||||
@@ -175,8 +225,26 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Run Trivy filesystem scanner
|
||||||
|
uses: aquasecurity/trivy-action@0.28.0
|
||||||
|
with:
|
||||||
|
scan-type: "fs"
|
||||||
|
scan-ref: "."
|
||||||
|
format: "sarif"
|
||||||
|
output: "trivy-fs-results.sarif"
|
||||||
|
severity: "CRITICAL,HIGH"
|
||||||
|
ignore-unfixed: true
|
||||||
|
scanners: "vuln,secret,misconfig"
|
||||||
|
|
||||||
|
- name: Upload Trivy SARIF (filesystem)
|
||||||
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
sarif_file: "trivy-fs-results.sarif"
|
||||||
|
category: "trivy-filesystem"
|
||||||
|
|
||||||
- name: Trivy filesystem table output
|
- name: Trivy filesystem table output
|
||||||
uses: aquasecurity/trivy-action@v0.36.0
|
uses: aquasecurity/trivy-action@0.28.0
|
||||||
with:
|
with:
|
||||||
scan-type: "fs"
|
scan-type: "fs"
|
||||||
scan-ref: "."
|
scan-ref: "."
|
||||||
|
|||||||
103
.github/workflows/smoke.yml
vendored
103
.github/workflows/smoke.yml
vendored
@@ -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
8
.gitignore
vendored
@@ -35,11 +35,3 @@ load-tests/results/*.json
|
|||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|
||||||
# Redis dump (created when running redis locally without persistence config)
|
|
||||||
*.rdb
|
|
||||||
|
|
||||||
# personal notes / Obsidian
|
|
||||||
.obsidian/
|
|
||||||
TEC/
|
|
||||||
*.canvas
|
|
||||||
|
|||||||
97
AGENTS.md
97
AGENTS.md
@@ -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
|
|
||||||
435
CHANGELOG.md
435
CHANGELOG.md
@@ -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/),
|
The format is based on [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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [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 Results (2026-04-12)
|
||||||
- QA_TRACKER.md — cập nhật test status baseline + Sprint 1-2 test plans
|
- Lint: PASS (0 errors)
|
||||||
- CHANGELOG.md — cập nhật changelog lần cuối (2026-04-22)
|
- TypeScript: 7 errors in web test files (vitest types missing) — TEC-1918
|
||||||
- PROJECT_TRACKER.md — cập nhật GOO-33 status → in_progress
|
- Unit Tests: 232 files, 1454 tests, ALL PASS
|
||||||
- CONTRIBUTING.md — thêm branching strategy, PR conventions, commit message format
|
- Build: ALL 3 packages build successfully
|
||||||
- docs/ci-cd.md — tài liệu đầy đủ GitHub Actions pipeline (lint → typecheck → test → build)
|
- Git: Clean working tree
|
||||||
- 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
|
|
||||||
|
|
||||||
### 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
|
### Identified (from CEO Audit 2026-04-11)
|
||||||
- Kiểm tra toàn diện codebase: 51 findings (4 blockers, 24 high, 13 medium, 10 low)
|
- 725 ESLint errors (712 auto-fixable) — TEC-1888
|
||||||
- Nghiên cứu thị trường BĐS VN: 23 findings (3 P0, 10 P1, 8 P2, 1 P3)
|
- TypeScript errors in web tests (json-ld.spec.tsx) — TEC-1888
|
||||||
- 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)
|
- 27 failing rate limit guard tests — TEC-1889
|
||||||
- Tạo 32 subtasks (GOO-3 → GOO-34) phân theo 6 sprints
|
- 3 incomplete API modules (health, metrics, mcp) — TEC-1890
|
||||||
- Tạo QA_TRACKER.md, cập nhật PROJECT_TRACKER.md
|
- 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
|
### Previously Added
|
||||||
- GOO-3: Fix double CSRF middleware — login/register/payment callbacks hoạt động (Sprint 1) ✅
|
- 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)
|
### Fixed
|
||||||
- GOO-4: UsageRecord atomic metering (fix quota bypass)
|
- MCP transport controller now requires JWT authentication (BUG-004 resolved)
|
||||||
- GOO-5: Rate-limit POST /auth/exchange-token
|
- 21 lint errors from GDPR/logger/caching commits
|
||||||
- GOO-6: Fix MoMo IPN URL (tách ipnUrl khỏi redirectUrl)
|
- Replaced `new Logger()` with DI LoggerService across modules
|
||||||
- GOO-7: JWT validate user status (isActive + deletedAt)
|
- 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)
|
### Changed
|
||||||
- Thiếu Phone-OTP login (phương thức auth chính ở VN)
|
- Split large files during logger refactor
|
||||||
- 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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [1.4.0] - 2026-04-08
|
## [1.4.0] - 2026-04-08
|
||||||
|
|
||||||
### Đã thêm
|
### Added
|
||||||
- Redis caching cho kiểm tra quota người dùng với xóa cache theo tiền tố
|
- Redis caching for user quota checks with prefix-based cache invalidation
|
||||||
- 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)
|
- Domain layer unit tests across all modules (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`
|
- Health check endpoints (`/health`, `/health/db`, `/health/redis`) using `@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
|
- Property Valuation UI with AVM (Automated Valuation Model) integration on the web frontend
|
||||||
|
|
||||||
### Đã thay đổi
|
### Changed
|
||||||
- Cải thiện cache service với các mẫu xóa theo tiền tố
|
- Improved cache service with prefix-based clearing patterns
|
||||||
- Nâng cao các handler truy vấn analytics với tầng caching
|
- Enhanced analytics query handlers with caching layer
|
||||||
|
|
||||||
### Đã sửa
|
### Fixed
|
||||||
- Giải quyết các lỗi lint trên toàn bộ codebase
|
- Lint errors resolved across codebase
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [1.3.0] - 2026-03-28
|
## [1.3.0] - 2026-03-28
|
||||||
|
|
||||||
### Đã thêm
|
### Added
|
||||||
- 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
|
- Complete notification delivery system with email (Nodemailer + Handlebars), push (Firebase Cloud Messaging), and in-app channels
|
||||||
- Trực quan hóa heatmap quận huyện bằng Mapbox và dashboard hiệu suất agent trên web frontend
|
- Mapbox district heatmap visualization and agent performance dashboard on web frontend
|
||||||
- Module đánh giá với đầy đủ các endpoint CRUD, các handler CQRS, và value object đánh giá 1-5 sao
|
- Reviews module with full CRUD endpoints, CQRS handlers, and 1-5 star rating value objects
|
||||||
- Kiểm thử đơn vị cho các module analytics, metrics, notifications, payments và search
|
- Unit tests for analytics, metrics, notifications, payments, and search modules
|
||||||
- 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
|
- Enhanced geo-search with PostGIS spatial queries and Typesense listing-approved event handlers
|
||||||
- Endpoint `/health` chuyên dụng với phản hồi timestamp
|
- Dedicated `/health` endpoint with timestamp response
|
||||||
|
|
||||||
### Đã thay đổi
|
### Changed
|
||||||
- Refactor nội bộ cache service và các handler analytics để tăng độ tin cậy
|
- Refactored cache service internals and analytics handlers for better reliability
|
||||||
|
|
||||||
### Đã sửa
|
### Fixed
|
||||||
- Thiếu các thuộc tính `AuthState` trong các mock kiểm thử web frontend
|
- Missing `AuthState` properties in web frontend test mocks
|
||||||
- Cải thiện quy trình E2E: bước Prisma generate, cache trình duyệt, trace artifacts
|
- E2E workflow improvements: Prisma generate step, browser cache, trace artifacts
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [1.2.0] - 2026-03-20
|
## [1.2.0] - 2026-03-20
|
||||||
|
|
||||||
### Đã thêm
|
### Added
|
||||||
- Tích hợp React Query cho data fetching với UX thử lại khi lỗi
|
- React Query integration for data fetching with error retry UX
|
||||||
- Nút chuyển đổi dark mode cho web frontend
|
- Dark mode toggle for web frontend
|
||||||
- Tầng Redis caching cho các đường dẫn hot của search và analytics
|
- Redis caching layer for search and analytics hot paths
|
||||||
- Pipeline NLP tiếng Việt (Underthesea) để phân tích mô tả bất động sản trong AI services
|
- Vietnamese NLP pipeline (Underthesea) for property description analysis in AI services
|
||||||
- `MetricsService`, `HttpMetricsInterceptor` Prometheus, và các hằng số metric tùy chỉnh
|
- Prometheus `MetricsService`, `HttpMetricsInterceptor`, and custom metric constants
|
||||||
- Trang Agent Profile, xác minh KYC, Subscription, và bảng điều khiển Payment trên web frontend
|
- Agent Profile, KYC verification, Subscription, and Payment dashboard pages on 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á)
|
- Unit tests for MCP servers (property search, market analytics, valuation)
|
||||||
- Kiểm thử đơn vị cho các hàm kiểm tra và tiện ích web frontend
|
- Unit tests for web frontend validations and utility functions
|
||||||
|
|
||||||
### Đã sửa
|
### Fixed
|
||||||
- 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
|
- Removed MinIO hardcoded credentials; added presigned URL support for media uploads
|
||||||
- Áp dụng kiểm tra JWT secret cho tất cả môi trường (không chỉ production)
|
- JWT secret enforcement in all environments (not just production)
|
||||||
- Thêm chỉ mục `Review.userId` còn thiếu để tăng hiệu suất truy vấn FK
|
- Added missing `Review.userId` index for FK query performance
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [1.1.0] - 2026-03-12
|
## [1.1.0] - 2026-03-12
|
||||||
|
|
||||||
### Đã thêm
|
### Added
|
||||||
- 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
|
- Listing duplicate detection service to prevent redundant property submissions
|
||||||
- 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
|
- Subscription quota enforcement with per-plan feature limits and usage metering
|
||||||
- Các chiến lược OAuth backend Google và Zalo cho đăng nhập mạng xã hội
|
- Google and Zalo OAuth backend strategies for social login
|
||||||
- 58 bài kiểm thử đơn vị bao phủ các đường dẫn auth, payment và subscription quan trọng
|
- 58 unit tests covering critical auth, payment, and subscription paths
|
||||||
- Skeleton loading, error boundary, và cải thiện khả năng truy cập trên web frontend
|
- Loading skeletons, error boundaries, and accessibility improvements on web frontend
|
||||||
- Tích hợp theo dõi lỗi Sentry cho cả API và ứng dụng web
|
- Sentry error tracking integration for both API and web apps
|
||||||
|
|
||||||
### Đã sửa
|
### Fixed
|
||||||
- Tăng cường cấu hình triển khai Docker production cho tất cả các dịch vụ
|
- Hardened production Docker deployment configuration for all services
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [1.0.0] - 2026-03-01
|
## [1.0.0] - 2026-03-01
|
||||||
|
|
||||||
### Đã thêm
|
### Added
|
||||||
|
|
||||||
#### Xác Thực & Bảo Mật
|
#### Authentication & Security
|
||||||
- Đăng ký và đăng nhập người dùng bằng số điện thoại và mật khẩu
|
- User registration and login with phone number and password
|
||||||
- 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)
|
- JWT access tokens (15-minute expiry) with refresh token rotation (7-day expiry)
|
||||||
- Phát hiện xoay vòng dựa trên token family để ngăn chặn tấn công replay
|
- Token family-based rotation detection to prevent replay attacks
|
||||||
- Hỗ trợ đăng nhập mạng xã hội OAuth (Google, Zalo)
|
- OAuth social login support (Google, Zalo)
|
||||||
- Quy trình xác minh KYC (Know Your Customer) (NONE -> PENDING -> VERIFIED/REJECTED)
|
- KYC (Know Your Customer) verification workflow (NONE -> PENDING -> VERIFIED/REJECTED)
|
||||||
- Kiểm soát truy cập theo vai trò với decorator `@Roles()` (USER, AGENT, ADMIN)
|
- Role-based access control with `@Roles()` decorator (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
|
- Rate limiting: 60 req/min default, 10 req/min auth, 20 req/min payment callbacks
|
||||||
- `ThrottlerBehindProxyGuard` để theo dõi IP nhận biết X-Forwarded-For
|
- `ThrottlerBehindProxyGuard` for X-Forwarded-For-aware IP tracking
|
||||||
- Tiêu đề bảo mật Helmet, cấu hình CORS
|
- Helmet security headers, CORS configuration
|
||||||
- Kiểm tra đầu vào (class-validator) và làm sạch nội dung (sanitize-html)
|
- Input validation (class-validator) and content sanitization (sanitize-html)
|
||||||
- Bảo vệ CSRF với mẫu double-submit cookie
|
- CSRF protection with double-submit cookie pattern
|
||||||
- Che giấu PII trong structured log (Pino)
|
- PII masking in structured logs (Pino)
|
||||||
- Băm mật khẩu Bcrypt
|
- Bcrypt password hashing
|
||||||
|
|
||||||
#### Danh Sách Bất Động Sản
|
#### Property Listings
|
||||||
- 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)
|
- Full CRUD for property listings with status state machine (DRAFT -> PENDING_REVIEW -> ACTIVE -> RESERVED -> SOLD/RENTED)
|
||||||
- Hỗ trợ tải lên media (S3/MinIO) với kiểm tra tệp
|
- Media upload support (S3/MinIO) with file validation
|
||||||
- Chấm điểm kiểm duyệt hỗ trợ bởi AI qua Claude API
|
- AI-assisted moderation scoring via Claude API
|
||||||
- Hàng đợi kiểm duyệt admin với phê duyệt/từ chối hàng loạt
|
- Admin moderation queue with bulk approve/reject
|
||||||
- Tạo danh sách bị giới hạn bởi quota gắn với gói subscription
|
- Quota-gated listing creation tied to subscription plans
|
||||||
|
|
||||||
#### Tìm Kiếm & Khám Phá
|
#### Search & Discovery
|
||||||
- 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
|
- Full-text property search via Typesense with Vietnamese language support
|
||||||
- Tìm kiếm địa lý không gian bằng PostGIS (truy vấn lat/long + bán kính)
|
- Geo-spatial search using PostGIS (lat/long + radius queries)
|
||||||
- Lọc nhiều mặt theo giá, loại bất động sản, số phòng ngủ, quận huyện
|
- Faceted filtering by price, property type, bedrooms, district
|
||||||
- Cập nhật chỉ mục tìm kiếm theo sự kiện (listing approved/updated/sold -> re-index)
|
- Event-driven search index updates (listing approved/updated/sold -> re-index)
|
||||||
- Xóa cache theo tiền tố cho kết quả tìm kiếm
|
- Prefix-based cache invalidation for search results
|
||||||
|
|
||||||
#### Thanh Toán
|
#### Payments
|
||||||
- Xử lý thanh toán với tích hợp các nhà cung cấp VNPay, MoMo và ZaloPay
|
- Payment processing with VNPay, MoMo, and ZaloPay provider integration
|
||||||
- Xử lý webhook callback idempotent với xác minh chữ ký
|
- Idempotent webhook callback handling with signature verification
|
||||||
- Hỗ trợ hoàn tiền
|
- Payment refund support
|
||||||
- Chuyển đổi trạng thái nguyên tử (PENDING -> COMPLETED/FAILED)
|
- Atomic status transitions (PENDING -> COMPLETED/FAILED)
|
||||||
- Phát sự kiện khi hoàn thành/thất bại thanh toán cho xử lý downstream
|
- Event emission on payment completion/failure for downstream processing
|
||||||
|
|
||||||
#### Subscription & Thanh Toán Định Kỳ
|
#### Subscriptions & Billing
|
||||||
- Các gói subscription với cờ tính năng phân tầng (cột JSON)
|
- Subscription plans with tiered feature flags (JSON columns)
|
||||||
- Đo lường mức sử dụng và kiểm tra quota (được hỗ trợ bởi Redis)
|
- Usage metering and quota enforcement (Redis-backed)
|
||||||
- Nâng cấp và hủy gói
|
- Plan upgrades and cancellations
|
||||||
- Theo dõi lịch sử thanh toán
|
- Billing history tracking
|
||||||
- Theo dõi mức sử dụng theo sự kiện (`listing.created` -> đo lường mức sử dụng)
|
- Event-driven usage tracking (`listing.created` -> meter usage)
|
||||||
|
|
||||||
#### Bảng Điều Khiển Admin
|
#### Admin Panel
|
||||||
- Dashboard với thống kê toàn hệ thống
|
- Dashboard with system-wide statistics
|
||||||
- Quản lý người dùng (liệt kê, xem, cấm/bỏ cấm)
|
- User management (list, view, ban/unban)
|
||||||
- Hàng đợi phê duyệt KYC với hành động phê duyệt/từ chối
|
- KYC approval queue with approve/reject actions
|
||||||
- Hàng đợi kiểm duyệt danh sách với kiểm duyệt hàng loạt
|
- Listing moderation queue with bulk moderation
|
||||||
- Thống kê doanh thu và analytics
|
- Revenue statistics and analytics
|
||||||
- Điều chỉnh subscription cho người dùng cá nhân
|
- Subscription adjustment for individual users
|
||||||
|
|
||||||
#### Analytics & Dữ Liệu Thị Trường
|
#### Analytics & Market Data
|
||||||
- Báo cáo thị trường theo quận huyện với tổng hợp không gian PostGIS
|
- District-level market reports with PostGIS spatial aggregation
|
||||||
- Phân tích xu hướng giá theo loại bất động sản và quận huyện
|
- Price trend analysis by property type and district
|
||||||
- Dữ liệu heatmap quận huyện (tổng hợp địa lý)
|
- District heatmap data (geo aggregates)
|
||||||
- Theo dõi và cập nhật chỉ số thị trường
|
- Market index tracking and updates
|
||||||
- Phân phối báo cáo dựa trên cache
|
- Cache-based report delivery
|
||||||
|
|
||||||
#### Thông Báo
|
#### Notifications
|
||||||
- Gửi thông báo đa kênh: EMAIL, SMS, PUSH (FCM), IN_APP
|
- Multi-channel notification delivery: 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
|
- 8 event-driven listeners: welcome email, KYC approval, listing approval/rejection, payment confirmation/failure, subscription expiry, quota exceeded
|
||||||
- Mẫu email Handlebars với bản địa hóa tiếng Việt
|
- Handlebars email templates with Vietnamese localization
|
||||||
- Tùy chọn thông báo người dùng (từ chối nhận theo kênh/loại)
|
- User notification preferences (opt-out per channel/type)
|
||||||
|
|
||||||
#### Đánh Giá
|
#### Reviews
|
||||||
- Đánh giá bất động sản và agent với xếp hạng 1-5 sao
|
- Property and agent reviews with 1-5 star ratings
|
||||||
- CRUD đánh giá với tính đa hình đối tượng (agent hoặc bất động sản)
|
- Review CRUD with target polymorphism (agent or property)
|
||||||
- Tính toán xếp hạng trung bình theo đối tượng
|
- 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`
|
- Property Search Server: `search_properties`, `compare_properties`, `get_property_details`
|
||||||
- Market Analytics Server: `get_market_report`, `analyze_trends`, `get_price_indices`
|
- Market Analytics Server: `get_market_report`, `analyze_trends`, `get_price_indices`
|
||||||
- Valuation Server: `estimate_valuation`, `extract_features`, `compare_valuations` (XGBoost qua FastAPI)
|
- Valuation Server: `estimate_valuation`, `extract_features`, `compare_valuations` (XGBoost via FastAPI)
|
||||||
- HTTP transport controller với `McpRegistryService`
|
- HTTP transport controller with `McpRegistryService`
|
||||||
|
|
||||||
#### Dịch Vụ AI
|
#### AI Services
|
||||||
- Microservice FastAPI với mô hình định giá bất động sản XGBoost
|
- FastAPI microservice with XGBoost property valuation model
|
||||||
- Kiểm duyệt nội dung mô tả danh sách được hỗ trợ bởi Claude API
|
- Claude API-powered content moderation for listing descriptions
|
||||||
- Tiền xử lý NLP tiếng Việt với Underthesea
|
- Vietnamese NLP preprocessing with Underthesea
|
||||||
|
|
||||||
#### Hạ Tầng
|
#### Infrastructure
|
||||||
- PostgreSQL 16 với extension PostGIS (22 model, chỉ mục không gian)
|
- PostgreSQL 16 with PostGIS extension (22 models, spatial indexes)
|
||||||
- Tầng Redis caching cho search, analytics, quota và dữ liệu phiên
|
- Redis caching layer for search, analytics, quota, and session data
|
||||||
- Công cụ tìm kiếm Typesense với hỗ trợ tiếng Việt
|
- Typesense search engine with Vietnamese language support
|
||||||
- Endpoint Prometheus metrics với histogram thời gian yêu cầu HTTP và bộ đếm tỷ lệ lỗi
|
- Prometheus metrics endpoint with HTTP request duration histograms and error rate counters
|
||||||
- Dashboard Grafana tự động cấu hình từ thư mục `monitoring/`
|
- Grafana dashboards auto-provisioned from `monitoring/` directory
|
||||||
- Ghi log JSON có cấu trúc Pino với correlation ID
|
- Pino structured JSON logging with correlation IDs
|
||||||
- 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)
|
- Prisma ORM with migration system and seed data (Ho Chi Minh City districts/wards, sample properties, subscription plans)
|
||||||
|
|
||||||
#### Frontend (Next.js 15)
|
#### Frontend (Next.js 15)
|
||||||
- App Router với Tailwind CSS và quản lý trạng thái Zustand
|
- App Router with Tailwind CSS and Zustand state management
|
||||||
- Trang tìm kiếm bất động sản với tích hợp bản đồ Mapbox GL
|
- Property search page with Mapbox GL map integration
|
||||||
- Trang chi tiết danh sách với thư viện media
|
- Listing detail pages with media gallery
|
||||||
- Dashboard agent với quản lý KYC, subscription và thanh toán
|
- Agent dashboard with KYC, subscription, and payment management
|
||||||
- Trực quan hóa heatmap quận huyện
|
- District heatmap visualization
|
||||||
- Giao diện định giá bất động sản với tích hợp AVM
|
- Property valuation UI with AVM integration
|
||||||
- Nút chuyển đổi dark mode
|
- Dark mode toggle
|
||||||
- Skeleton loading và error boundary
|
- Loading skeletons and error boundaries
|
||||||
- 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)
|
- Vietnamese UI text throughout (property types, districts, currency in VND)
|
||||||
|
|
||||||
#### Trải Nghiệm Nhà Phát Triển
|
#### Developer Experience
|
||||||
- Monorepo với pnpm workspaces và Turborepo
|
- Monorepo with pnpm workspaces and Turborepo
|
||||||
- ESLint với các quy tắc sắp xếp import
|
- ESLint with import ordering rules
|
||||||
- Định dạng code Prettier
|
- Prettier code formatting
|
||||||
- Git hook Husky
|
- Husky git hooks
|
||||||
- Kiểm thử E2E với Playwright (14 tệp kiểm thử web)
|
- E2E tests with Playwright (14 web test files)
|
||||||
- CI pipeline GitHub Actions (lint -> typecheck -> test -> build)
|
- GitHub Actions CI pipeline (lint -> typecheck -> test -> build)
|
||||||
|
|
||||||
### Bảo Mật
|
### Security
|
||||||
- Lưu trữ token dựa trên cookie httpOnly với tăng cường CSRF
|
- httpOnly cookie-based token storage with CSRF hardening
|
||||||
- Khóa idempotency trên các luồng thanh toán với kiểm tra số tiền
|
- Idempotency keys on payment flows with amount validation
|
||||||
- Kiểm tra magic byte cho tệp tải lên media
|
- Magic byte file validation for media uploads
|
||||||
- Ghi log kiểm tra admin
|
- Admin audit logging
|
||||||
- Kiểm tra audience/issuer JWT
|
- JWT audience/issuer validation
|
||||||
- Kiểm tra biến môi trường production
|
- Production environment variable validation
|
||||||
- `.env.example` được làm sạch (không rò rỉ bí mật)
|
- Sanitized `.env.example` (no leaked secrets)
|
||||||
- Hook tắt dịch vụ nhẹ nhàng để kết thúc tiến trình sạch
|
- Graceful shutdown hooks for clean process termination
|
||||||
|
|
||||||
[Unreleased]: https://github.com/goodgo/platform-ai/compare/v1.4.0...HEAD
|
[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
|
[1.4.0]: https://github.com/goodgo/platform-ai/compare/v1.3.0...v1.4.0
|
||||||
|
|||||||
359
CONTRIBUTING.md
359
CONTRIBUTING.md
@@ -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.
|
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.
|
||||||
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.
|
|
||||||
|
|
||||||
### Flow nhanh cho mỗi task
|
The `GlobalExceptionFilter` catches all exceptions and normalizes them into a consistent JSON response with structured error codes.
|
||||||
|
|
||||||
```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` và `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** và **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.
|
|
||||||
|
|
||||||
### Domain Exceptions
|
### 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 |
|
| `NotFoundException(entity, id?)` | 404 | Entity not found in 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 |
|
| `ValidationException(message, details?)` | 400 | Invalid input, business rule violation, value object creation failure |
|
||||||
| `ConflictException(message)` | 409 | Tài nguyên bị trùng lặp, vi phạm idempotency |
|
| `ConflictException(message)` | 409 | Duplicate resource, idempotency violation |
|
||||||
| `UnauthorizedException(message?)` | 401 | Thông tin xác thực hoặc token không hợp lệ/đã hết hạn |
|
| `UnauthorizedException(message?)` | 401 | Invalid/expired credentials or tokens |
|
||||||
| `ForbiddenException(message?)` | 403 | Đã xác thực nhưng không được phép thực hiện hành động |
|
| `ForbiddenException(message?)` | 403 | Authenticated but not authorized for the action |
|
||||||
|
|
||||||
Import từ:
|
Import from:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { NotFoundException, ValidationException } from '@modules/shared/domain/domain-exception';
|
import { NotFoundException, ValidationException } from '@modules/shared/domain/domain-exception';
|
||||||
```
|
```
|
||||||
|
|
||||||
### Các Mẫu Theo Từng Tầng
|
### Patterns by Layer
|
||||||
|
|
||||||
#### Command/Query Handlers
|
#### 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
|
```typescript
|
||||||
// Good: domain exception with entity context
|
// Good: domain exception with entity context
|
||||||
@@ -286,11 +50,11 @@ if (subscription.status === 'CANCELLED') {
|
|||||||
|
|
||||||
#### Controllers
|
#### 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
|
#### 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
|
```typescript
|
||||||
static create(value: string): Result<Phone, string> {
|
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
|
```typescript
|
||||||
// Bad: NestJS built-in exceptions (missing errorCode in response)
|
// Bad: NestJS built-in exceptions (missing errorCode in response)
|
||||||
@@ -321,89 +85,8 @@ try {
|
|||||||
// Handlers should unwrap Result and throw on error
|
// 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.
|
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.
|
||||||
|
|
||||||
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! 🚀**
|
|
||||||
|
|
||||||
|
See `listing-read.dto.ts` for the canonical example.
|
||||||
|
|||||||
106
README.md
106
README.md
@@ -1,23 +1,23 @@
|
|||||||
# GoodGo Platform AI
|
# 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 |
|
| **Backend** | NestJS 11, TypeScript, Prisma ORM, CQRS |
|
||||||
| **Frontend** | Next.js 15, React 18, Tailwind CSS, Zustand |
|
| **Frontend** | Next.js 15, React 18, Tailwind CSS, Zustand |
|
||||||
| **Cơ sở dữ liệu** | PostgreSQL 16 + PostGIS 3.4 |
|
| **Database** | PostgreSQL 16 + PostGIS 3.4 |
|
||||||
| **Tìm kiếm** | Typesense 27 |
|
| **Search** | Typesense 27 |
|
||||||
| **Cache/Queue** | Redis 7 |
|
| **Cache/Queue** | Redis 7 |
|
||||||
| **AI/ML** | FastAPI, XGBoost, Claude API, Underthesea |
|
| **AI/ML** | FastAPI, XGBoost, Claude API, Underthesea |
|
||||||
| **MCP** | Model Context Protocol servers (tìm kiếm nhà đất, định giá, phân tích) |
|
| **MCP** | Model Context Protocol servers (property search, valuation, analytics) |
|
||||||
| **Lưu trữ** | MinIO (tương thích S3) |
|
| **Storage** | MinIO (S3-compatible) |
|
||||||
| **Giám sát** | Prometheus, Grafana, Loki + Promtail |
|
| **Monitoring** | Prometheus, Grafana, Loki + Promtail |
|
||||||
| **Thanh toán** | VNPay, MoMo, ZaloPay |
|
| **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/
|
goodgo-platform-ai/
|
||||||
@@ -64,15 +64,15 @@ goodgo-platform-ai/
|
|||||||
└── docs/ # Developer documentation
|
└── docs/ # Developer documentation
|
||||||
```
|
```
|
||||||
|
|
||||||
## Khởi Động Nhanh
|
## Quick Start
|
||||||
|
|
||||||
### Yêu Cầu Tiên Quyết
|
### Prerequisites
|
||||||
|
|
||||||
- **Docker Engine 24+** & Docker Compose v2
|
- **Docker Engine 24+** & Docker Compose v2
|
||||||
- **Node.js 22 LTS**
|
- **Node.js 22 LTS**
|
||||||
- **pnpm 10.27+** (`corepack enable && corepack prepare pnpm@latest --activate`)
|
- **pnpm 10.27+** (`corepack enable && corepack prepare pnpm@latest --activate`)
|
||||||
|
|
||||||
### Cài Đặt
|
### Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clone the repository
|
# 1. Clone the repository
|
||||||
@@ -103,26 +103,26 @@ pnpm db:seed
|
|||||||
pnpm dev
|
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 | — |
|
| PostgreSQL + PostGIS | 5432 | — |
|
||||||
| Redis | 6379 | — |
|
| Redis | 6379 | — |
|
||||||
| Typesense | 8108 | `http://localhost:8108/health` |
|
| Typesense | 8108 | `http://localhost:8108/health` |
|
||||||
| MinIO | 9000 / 9001 | `http://localhost:9001` (console) |
|
| MinIO | 9000 / 9001 | `http://localhost:9001` (console) |
|
||||||
| AI Services (FastAPI) | 8000 | `http://localhost:8000/health` |
|
| 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` |
|
| Prometheus | 9090 | `http://localhost:9090` |
|
||||||
| Grafana | 3002 | `http://localhost:3002` |
|
| Grafana | 3002 | `http://localhost:3002` |
|
||||||
|
|
||||||
## Phát Triển
|
## Development
|
||||||
|
|
||||||
### Các Lệnh Thông Dụng
|
### Common Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm dev # Start all apps (API + Web)
|
pnpm dev # Start all apps (API + Web)
|
||||||
@@ -133,7 +133,7 @@ pnpm format # Format with Prettier
|
|||||||
pnpm test # Run unit/integration tests
|
pnpm test # Run unit/integration tests
|
||||||
```
|
```
|
||||||
|
|
||||||
### Cơ Sở Dữ Liệu
|
### Database
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm db:generate # Regenerate Prisma client
|
pnpm db:generate # Regenerate Prisma client
|
||||||
@@ -144,7 +144,7 @@ pnpm db:studio # Open Prisma Studio (visual editor)
|
|||||||
pnpm db:reset # Reset database (destructive)
|
pnpm db:reset # Reset database (destructive)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Kiểm Thử E2E
|
### E2E Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm test:e2e # Run all E2E tests
|
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
|
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/` và `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 |
|
| **auth** | Registration, login, JWT + refresh token rotation, OAuth (Google/Zalo), KYC, user data export/deletion |
|
||||||
| **listings** | CRUD tin đăng nhà đất, quy trình trạng thái, quản lý tệp phương tiện |
|
| **listings** | Property listing CRUD, status workflow, media management |
|
||||||
| **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 |
|
| **search** | Typesense full-text search with geo-spatial filters, saved searches |
|
||||||
| **payments** | Tích hợp VNPay, MoMo, ZaloPay kèm xác thực callback |
|
| **payments** | VNPay, MoMo, ZaloPay integration with callback verification |
|
||||||
| **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 |
|
| **subscriptions** | Plan management, usage tracking, quota enforcement |
|
||||||
| **notifications** | Lịch sử thông báo qua email và trong ứng dụng cùng tuỳ chọn cá nhân |
|
| **notifications** | Email and in-app notification history & preferences |
|
||||||
| **admin** | Kiểm duyệt tin đăng, quản lý người dùng, nhật ký kiểm tra |
|
| **admin** | Listing moderation, user management, audit logs |
|
||||||
| **analytics** | Báo cáo thị trường, chỉ số giá, tích hợp AVM |
|
| **analytics** | Market reports, price indices, AVM integration |
|
||||||
| **agents** | Hồ sơ và xác minh môi giới bất động sản |
|
| **agents** | Real estate agent profiles and verification |
|
||||||
| **inquiries** | Quản lý yêu cầu tư vấn nhà đất |
|
| **inquiries** | Property inquiry management |
|
||||||
| **leads** | Theo dõi và chuyển đổi khách hàng tiềm năng |
|
| **leads** | Lead tracking and conversion |
|
||||||
| **reviews** | Đánh giá và xếp hạng bất động sản |
|
| **reviews** | Property reviews and ratings |
|
||||||
| **health** | Kiểm tra liveness và readiness |
|
| **health** | Liveness and readiness health checks |
|
||||||
| **mcp** | Cầu nối MCP server (tìm kiếm nhà đất, định giá, phân tích) |
|
| **mcp** | MCP server bridge (property search, valuation, analytics) |
|
||||||
| **metrics** | Thu thập metrics Prometheus và web vitals |
|
| **metrics** | Prometheus metrics and web vitals collection |
|
||||||
| **shared** | Mối quan tâm chung: guards, pipes, filters, dịch vụ Prisma/Redis |
|
| **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ộ |
|
| [Development Environment](docs/dev-environment.md) | Docker setup and local services |
|
||||||
| [Kiến trúc](docs/architecture.md) | Thiết kế hệ thống, luồng dữ liệu, cấu trúc module |
|
| [Architecture](docs/architecture.md) | System design, data flow, module structure |
|
||||||
| [API Endpoints](docs/api-endpoints.md) | Tài liệu tham khảo REST API endpoint |
|
| [API Endpoints](docs/api-endpoints.md) | REST API endpoint reference |
|
||||||
| [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 |
|
| [API Error Codes](docs/api-error-codes.md) | Error response format and all error codes |
|
||||||
| [Triển khai](docs/deployment.md) | Hướng dẫn triển khai môi trường sản xuất |
|
| [Deployment](docs/deployment.md) | Production deployment guide |
|
||||||
| [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ố |
|
| [Backup & Restore](docs/backup-restore.md) | Backup procedures and disaster recovery |
|
||||||
| [Đóng góp](CONTRIBUTING.md) | Quy ước xử lý lỗi và các mẫu lập trình |
|
| [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.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ set -e
|
|||||||
|
|
||||||
if [ "${RUN_MIGRATIONS}" = "true" ]; then
|
if [ "${RUN_MIGRATIONS}" = "true" ]; then
|
||||||
echo "[entrypoint] Running Prisma migrations..."
|
echo "[entrypoint] Running Prisma migrations..."
|
||||||
npx prisma migrate deploy --schema /app/prisma/schema.prisma
|
npx prisma migrate deploy --schema ./prisma/schema.prisma
|
||||||
echo "[entrypoint] Migrations complete."
|
echo "[entrypoint] Migrations complete."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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" }]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -16,12 +16,7 @@
|
|||||||
"@anthropic-ai/sdk": "^0.89.0",
|
"@anthropic-ai/sdk": "^0.89.0",
|
||||||
"@aws-sdk/client-s3": "^3.1026.0",
|
"@aws-sdk/client-s3": "^3.1026.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1026.0",
|
"@aws-sdk/s3-request-presigner": "^3.1026.0",
|
||||||
"@bull-board/api": "^7.0.0",
|
|
||||||
"@bull-board/express": "^7.0.0",
|
|
||||||
"@bull-board/nestjs": "^7.0.0",
|
|
||||||
"@goodgo/mcp-servers": "workspace:*",
|
"@goodgo/mcp-servers": "workspace:*",
|
||||||
"@goodgo/contracts-events": "workspace:*",
|
|
||||||
"@nest-lab/throttler-storage-redis": "^1.2.0",
|
|
||||||
"@nestjs/bullmq": "^11.0.4",
|
"@nestjs/bullmq": "^11.0.4",
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
"@nestjs/config": "^4.0.4",
|
"@nestjs/config": "^4.0.4",
|
||||||
@@ -42,7 +37,6 @@
|
|||||||
"@prisma/client": "^7.7.0",
|
"@prisma/client": "^7.7.0",
|
||||||
"@sentry/nestjs": "^10.47.0",
|
"@sentry/nestjs": "^10.47.0",
|
||||||
"@sentry/profiling-node": "^10.47.0",
|
"@sentry/profiling-node": "^10.47.0",
|
||||||
"@socket.io/redis-adapter": "^8.3.0",
|
|
||||||
"@willsoto/nestjs-prometheus": "^6.1.0",
|
"@willsoto/nestjs-prometheus": "^6.1.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bullmq": "^5.74.1",
|
"bullmq": "^5.74.1",
|
||||||
@@ -53,7 +47,6 @@
|
|||||||
"handlebars": "^4.7.9",
|
"handlebars": "^4.7.9",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"ioredis": "^5.4.0",
|
"ioredis": "^5.4.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
|
||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^8.0.5",
|
||||||
"otplib": "^13.4.0",
|
"otplib": "^13.4.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
@@ -80,7 +73,6 @@
|
|||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
|
||||||
"@types/node": "^25.5.2",
|
"@types/node": "^25.5.2",
|
||||||
"@types/nodemailer": "^8.0.0",
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/passport-google-oauth20": "^2.0.17",
|
"@types/passport-google-oauth20": "^2.0.17",
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
|
|
||||||
import { BullModule } from '@nestjs/bullmq';
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
import { type MiddlewareConsumer, Module, type NestModule, RequestMethod } from '@nestjs/common';
|
import { type MiddlewareConsumer, Module, type NestModule, RequestMethod } from '@nestjs/common';
|
||||||
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
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 { AgentsModule } from '@modules/agents';
|
||||||
import { AnalyticsModule } from '@modules/analytics';
|
import { AnalyticsModule } from '@modules/analytics';
|
||||||
import { AuthModule } from '@modules/auth';
|
import { AuthModule } from '@modules/auth';
|
||||||
import { FavoritesModule } from '@modules/favorites';
|
|
||||||
import { HealthModule } from '@modules/health';
|
import { HealthModule } from '@modules/health';
|
||||||
import { IndustrialModule } from '@modules/industrial';
|
import { IndustrialModule } from '@modules/industrial';
|
||||||
import { InquiriesModule } from '@modules/inquiries';
|
import { InquiriesModule } from '@modules/inquiries';
|
||||||
@@ -20,11 +18,7 @@ import { McpIntegrationModule } from '@modules/mcp';
|
|||||||
import { MessagingModule } from '@modules/messaging';
|
import { MessagingModule } from '@modules/messaging';
|
||||||
import { HttpMetricsInterceptor, MetricsModule } from '@modules/metrics';
|
import { HttpMetricsInterceptor, MetricsModule } from '@modules/metrics';
|
||||||
import { NotificationsModule } from '@modules/notifications';
|
import { NotificationsModule } from '@modules/notifications';
|
||||||
import { OsmSyncModule } from '@modules/osm-sync/osm-sync.module';
|
|
||||||
import { PaymentsModule } from '@modules/payments';
|
import { PaymentsModule } from '@modules/payments';
|
||||||
import { PoiModule } from '@modules/poi/poi.module';
|
|
||||||
import { ProjectsModule } from '@modules/projects';
|
|
||||||
import { QueuesModule } from '@modules/queues/queues.module';
|
|
||||||
import { ReportsModule } from '@modules/reports';
|
import { ReportsModule } from '@modules/reports';
|
||||||
import { ReviewsModule } from '@modules/reviews';
|
import { ReviewsModule } from '@modules/reviews';
|
||||||
import { SearchModule } from '@modules/search';
|
import { SearchModule } from '@modules/search';
|
||||||
@@ -32,7 +26,6 @@ import { SharedModule } from '@modules/shared';
|
|||||||
import { ThrottlerBehindProxyGuard } from '@modules/shared/infrastructure/guards/throttler-behind-proxy.guard';
|
import { ThrottlerBehindProxyGuard } from '@modules/shared/infrastructure/guards/throttler-behind-proxy.guard';
|
||||||
import { CsrfMiddleware } from '@modules/shared/infrastructure/middleware/csrf.middleware';
|
import { CsrfMiddleware } from '@modules/shared/infrastructure/middleware/csrf.middleware';
|
||||||
import { SanitizeInputMiddleware } from '@modules/shared/infrastructure/middleware/sanitize-input.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 { SubscriptionsModule } from '@modules/subscriptions';
|
||||||
import { TransferModule } from '@modules/transfer';
|
import { TransferModule } from '@modules/transfer';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
@@ -41,11 +34,11 @@ import { AppController } from './app.controller';
|
|||||||
imports: [
|
imports: [
|
||||||
SentryModule.forRoot(),
|
SentryModule.forRoot(),
|
||||||
BullModule.forRoot({
|
BullModule.forRoot({
|
||||||
// RFC-004 Phase 3 — use the queue-specific Redis connection so ops can
|
connection: {
|
||||||
// split cache traffic from queue traffic without a code change. Falls
|
host: process.env['REDIS_HOST'] ?? 'localhost',
|
||||||
// back to REDIS_HOST/PORT/PASSWORD when the queue-specific vars are
|
port: Number(process.env['REDIS_PORT'] ?? 6379),
|
||||||
// unset. See shared/infrastructure/redis-connection.config.ts.
|
password: process.env['REDIS_PASSWORD'] ?? undefined,
|
||||||
connection: getRedisConnection('queue'),
|
},
|
||||||
}),
|
}),
|
||||||
CqrsModule.forRoot(),
|
CqrsModule.forRoot(),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
@@ -57,32 +50,22 @@ import { AppController } from './app.controller';
|
|||||||
LeadsModule,
|
LeadsModule,
|
||||||
ListingsModule,
|
ListingsModule,
|
||||||
ReviewsModule,
|
ReviewsModule,
|
||||||
FavoritesModule,
|
|
||||||
SearchModule,
|
SearchModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
OsmSyncModule,
|
|
||||||
PaymentsModule,
|
PaymentsModule,
|
||||||
PoiModule,
|
|
||||||
SubscriptionsModule,
|
SubscriptionsModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
AnalyticsModule,
|
AnalyticsModule,
|
||||||
MetricsModule,
|
MetricsModule,
|
||||||
MetricsModule.withQueueMetrics(),
|
|
||||||
McpIntegrationModule,
|
McpIntegrationModule,
|
||||||
MessagingModule,
|
MessagingModule,
|
||||||
ReportsModule,
|
ReportsModule,
|
||||||
ProjectsModule,
|
|
||||||
IndustrialModule,
|
IndustrialModule,
|
||||||
TransferModule,
|
TransferModule,
|
||||||
|
|
||||||
// ── Bull Board UI (RFC-004 Phase 3 WS3b) ──
|
|
||||||
QueuesModule,
|
|
||||||
|
|
||||||
// ── Rate Limiting ──
|
// ── Rate Limiting ──
|
||||||
// Default: 60 requests per 60 seconds per IP
|
// Default: 60 requests per 60 seconds per IP
|
||||||
// Override per-route with @Throttle() decorator
|
// Override per-route with @Throttle() decorator
|
||||||
// Storage: Redis-backed sliding window so limits are shared across
|
|
||||||
// every API instance (required for TEC-2930 feature-listing throttling).
|
|
||||||
ThrottlerModule.forRoot({
|
ThrottlerModule.forRoot({
|
||||||
throttlers: [
|
throttlers: [
|
||||||
{
|
{
|
||||||
@@ -101,21 +84,6 @@ import { AppController } from './app.controller';
|
|||||||
limit: process.env['NODE_ENV'] === 'test' || process.env['NODE_ENV'] === 'development' ? 10_000 : 20,
|
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],
|
controllers: [AppController],
|
||||||
@@ -150,10 +118,6 @@ export class AppModule implements NestModule {
|
|||||||
.exclude(
|
.exclude(
|
||||||
{ path: 'health', method: RequestMethod.GET },
|
{ path: 'health', method: RequestMethod.GET },
|
||||||
{ 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('*');
|
.forRoutes('*');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const isTest = process.env['NODE_ENV'] === 'test';
|
|||||||
const integrations: any[] = [];
|
const integrations: any[] = [];
|
||||||
if (!isTest) {
|
if (!isTest) {
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// 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');
|
const { nodeProfilingIntegration } = require('@sentry/profiling-node') as typeof import('@sentry/profiling-node');
|
||||||
integrations.push(nodeProfilingIntegration());
|
integrations.push(nodeProfilingIntegration());
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import './instrument';
|
|||||||
|
|
||||||
import { RequestMethod, ValidationPipe } from '@nestjs/common';
|
import { RequestMethod, ValidationPipe } from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { IoAdapter } from '@nestjs/platform-socket.io';
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import { LoggerService, RedisIoAdapter, validateEnv } from '@modules/shared';
|
import { LoggerService, validateEnv } from '@modules/shared';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
@@ -59,11 +60,7 @@ async function bootstrap() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── WebSocket Adapter (Socket.IO) ──
|
// ── WebSocket Adapter (Socket.IO) ──
|
||||||
// Redis pub/sub fan-out for multi-instance broadcasts; falls back to the
|
app.useWebSocketAdapter(new IoAdapter(app));
|
||||||
// in-memory IoAdapter when Redis is unreachable (single-node / local dev).
|
|
||||||
const wsAdapter = new RedisIoAdapter(app);
|
|
||||||
await wsAdapter.connectToRedis();
|
|
||||||
app.useWebSocketAdapter(wsAdapter);
|
|
||||||
|
|
||||||
// ── Security Headers (Helmet) ──
|
// ── Security Headers (Helmet) ──
|
||||||
app.use(
|
app.use(
|
||||||
|
|||||||
@@ -1,43 +1,30 @@
|
|||||||
import { forwardRef, Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { CqrsModule } from '@nestjs/cqrs';
|
import { CqrsModule } from '@nestjs/cqrs';
|
||||||
import { AuthModule } from '@modules/auth';
|
import { AuthModule } from '@modules/auth';
|
||||||
import { ListingsModule } from '@modules/listings';
|
import { ListingsModule } from '@modules/listings';
|
||||||
import { AI_CONFIG_PROVIDER } from '@modules/shared';
|
|
||||||
import { SubscriptionsModule } from '@modules/subscriptions';
|
import { SubscriptionsModule } from '@modules/subscriptions';
|
||||||
import { AdjustSubscriptionHandler } from './application/commands/adjust-subscription/adjust-subscription.handler';
|
import { AdjustSubscriptionHandler } from './application/commands/adjust-subscription/adjust-subscription.handler';
|
||||||
import { ApproveKycHandler } from './application/commands/approve-kyc/approve-kyc.handler';
|
import { ApproveKycHandler } from './application/commands/approve-kyc/approve-kyc.handler';
|
||||||
import { ApproveListingHandler } from './application/commands/approve-listing/approve-listing.handler';
|
import { ApproveListingHandler } from './application/commands/approve-listing/approve-listing.handler';
|
||||||
import { BanUserHandler } from './application/commands/ban-user/ban-user.handler';
|
import { BanUserHandler } from './application/commands/ban-user/ban-user.handler';
|
||||||
import { BulkModerateListingsHandler } from './application/commands/bulk-moderate-listings/bulk-moderate-listings.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 { RejectKycHandler } from './application/commands/reject-kyc/reject-kyc.handler';
|
||||||
import { RejectListingHandler } from './application/commands/reject-listing/reject-listing.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 { UpdateUserStatusHandler } from './application/commands/update-user-status/update-user-status.handler';
|
||||||
import { AdminAuditListener } from './application/listeners/admin-audit.listener';
|
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 { UserBannedListener } from './application/listeners/user-banned.listener';
|
||||||
import { UserDeactivatedListener } from './application/listeners/user-deactivated.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 { GetAuditLogsHandler } from './application/queries/get-audit-logs/get-audit-logs.handler';
|
||||||
import { GetDashboardStatsHandler } from './application/queries/get-dashboard-stats/get-dashboard-stats.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 { 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 { GetModerationQueueHandler } from './application/queries/get-moderation-queue/get-moderation-queue.handler';
|
||||||
import { GetRevenueStatsHandler } from './application/queries/get-revenue-stats/get-revenue-stats.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 { GetUserDetailHandler } from './application/queries/get-user-detail/get-user-detail.handler';
|
||||||
import { GetUsersHandler } from './application/queries/get-users/get-users.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 { ADMIN_QUERY_REPOSITORY } from './domain/repositories/admin-query.repository';
|
||||||
import { AUDIT_LOG_REPOSITORY } from './domain/repositories/audit-log.repository';
|
import { AUDIT_LOG_REPOSITORY } from './domain/repositories/audit-log.repository';
|
||||||
import { MODERATION_AUDIT_LOG_REPOSITORY } from './domain/repositories/moderation-audit-log.repository';
|
|
||||||
import { SystemSettingsAiConfigProvider } from './infrastructure/adapters/system-settings-ai-config.provider';
|
|
||||||
import { PrismaAdminQueryRepository } from './infrastructure/repositories/prisma-admin-query.repository';
|
import { PrismaAdminQueryRepository } from './infrastructure/repositories/prisma-admin-query.repository';
|
||||||
import { PrismaAuditLogRepository } from './infrastructure/repositories/prisma-audit-log.repository';
|
import { PrismaAuditLogRepository } from './infrastructure/repositories/prisma-audit-log.repository';
|
||||||
import { PrismaModerationAuditLogRepository } from './infrastructure/repositories/prisma-moderation-audit-log.repository';
|
|
||||||
import { AdminModerationAuditController } from './presentation/controllers/admin-moderation-audit.controller';
|
|
||||||
import { AdminModerationController } from './presentation/controllers/admin-moderation.controller';
|
import { AdminModerationController } from './presentation/controllers/admin-moderation.controller';
|
||||||
import { AdminController } from './presentation/controllers/admin.controller';
|
import { AdminController } from './presentation/controllers/admin.controller';
|
||||||
|
|
||||||
@@ -50,43 +37,25 @@ const CommandHandlers = [
|
|||||||
ApproveKycHandler,
|
ApproveKycHandler,
|
||||||
RejectKycHandler,
|
RejectKycHandler,
|
||||||
BulkModerateListingsHandler,
|
BulkModerateListingsHandler,
|
||||||
UpdateAiSettingsHandler,
|
|
||||||
ProvisionDeveloperHandler,
|
|
||||||
ProvisionParkOperatorHandler,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const QueryHandlers = [
|
const QueryHandlers = [
|
||||||
GetModerationQueueHandler,
|
GetModerationQueueHandler,
|
||||||
GetFlaggedListingsHandler,
|
|
||||||
GetDashboardStatsHandler,
|
GetDashboardStatsHandler,
|
||||||
GetRevenueStatsHandler,
|
GetRevenueStatsHandler,
|
||||||
GetUsersHandler,
|
GetUsersHandler,
|
||||||
GetUserDetailHandler,
|
GetUserDetailHandler,
|
||||||
GetKycQueueHandler,
|
GetKycQueueHandler,
|
||||||
GetAuditLogsHandler,
|
GetAuditLogsHandler,
|
||||||
GetModerationAuditLogsHandler,
|
|
||||||
GetAiSettingsHandler,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CqrsModule, AuthModule, forwardRef(() => ListingsModule), SubscriptionsModule],
|
imports: [CqrsModule, AuthModule, ListingsModule, SubscriptionsModule],
|
||||||
controllers: [
|
controllers: [AdminController, AdminModerationController],
|
||||||
AdminController,
|
|
||||||
AdminModerationController,
|
|
||||||
AdminModerationAuditController,
|
|
||||||
],
|
|
||||||
providers: [
|
providers: [
|
||||||
// Repositories
|
// Repositories
|
||||||
{ provide: ADMIN_QUERY_REPOSITORY, useClass: PrismaAdminQueryRepository },
|
{ provide: ADMIN_QUERY_REPOSITORY, useClass: PrismaAdminQueryRepository },
|
||||||
{ provide: AUDIT_LOG_REPOSITORY, useClass: PrismaAuditLogRepository },
|
{ provide: AUDIT_LOG_REPOSITORY, useClass: PrismaAuditLogRepository },
|
||||||
{
|
|
||||||
provide: MODERATION_AUDIT_LOG_REPOSITORY,
|
|
||||||
useClass: PrismaModerationAuditLogRepository,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Services
|
|
||||||
SystemSettingsService,
|
|
||||||
{ provide: AI_CONFIG_PROVIDER, useClass: SystemSettingsAiConfigProvider },
|
|
||||||
|
|
||||||
// CQRS
|
// CQRS
|
||||||
...CommandHandlers,
|
...CommandHandlers,
|
||||||
@@ -96,8 +65,6 @@ const QueryHandlers = [
|
|||||||
UserBannedListener,
|
UserBannedListener,
|
||||||
UserDeactivatedListener,
|
UserDeactivatedListener,
|
||||||
AdminAuditListener,
|
AdminAuditListener,
|
||||||
ModerationAuditListener,
|
|
||||||
],
|
],
|
||||||
exports: [SystemSettingsService, AI_CONFIG_PROVIDER],
|
|
||||||
})
|
})
|
||||||
export class AdminModule {}
|
export class AdminModule {}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
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 { 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 { SUBSCRIPTION_REPOSITORY, type ISubscriptionRepository } from '@modules/subscriptions';
|
||||||
import { SubscriptionAdjustedEvent } from '../../../domain/events/subscription-adjusted.event';
|
import { SubscriptionAdjustedEvent } from '../../../domain/events/subscription-adjusted.event';
|
||||||
import { AdjustSubscriptionCommand } from './adjust-subscription.command';
|
import { AdjustSubscriptionCommand } from './adjust-subscription.command';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
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 { 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 { KycApprovedEvent } from '../../../domain/events/kyc-approved.event';
|
||||||
import { ApproveKycCommand } from './approve-kyc.command';
|
import { ApproveKycCommand } from './approve-kyc.command';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
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 { 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 { ListingApprovedEvent } from '../../../domain/events/listing-approved.event';
|
||||||
import { ApproveListingCommand } from './approve-listing.command';
|
import { ApproveListingCommand } from './approve-listing.command';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
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 { 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 { UserBannedEvent } from '../../../domain/events/user-banned.event';
|
||||||
import { UserUnbannedEvent } from '../../../domain/events/user-unbanned.event';
|
import { UserUnbannedEvent } from '../../../domain/events/user-unbanned.event';
|
||||||
import { BanUserCommand } from './ban-user.command';
|
import { BanUserCommand } from './ban-user.command';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
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 { 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 { ListingApprovedEvent } from '../../../domain/events/listing-approved.event';
|
||||||
import { ListingRejectedEvent } from '../../../domain/events/listing-rejected.event';
|
import { ListingRejectedEvent } from '../../../domain/events/listing-rejected.event';
|
||||||
import { BulkModerateListingsCommand } from './bulk-moderate-listings.command';
|
import { BulkModerateListingsCommand } from './bulk-moderate-listings.command';
|
||||||
|
|||||||
@@ -14,5 +14,3 @@ export { RejectKycCommand } from './reject-kyc/reject-kyc.command';
|
|||||||
export { RejectKycHandler } from './reject-kyc/reject-kyc.handler';
|
export { RejectKycHandler } from './reject-kyc/reject-kyc.handler';
|
||||||
export { BulkModerateListingsCommand } from './bulk-moderate-listings/bulk-moderate-listings.command';
|
export { BulkModerateListingsCommand } from './bulk-moderate-listings/bulk-moderate-listings.command';
|
||||||
export { BulkModerateListingsHandler } from './bulk-moderate-listings/bulk-moderate-listings.handler';
|
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';
|
|
||||||
|
|||||||
@@ -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[],
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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[],
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
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 { 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 { KycRejectedEvent } from '../../../domain/events/kyc-rejected.event';
|
||||||
import { RejectKycCommand } from './reject-kyc.command';
|
import { RejectKycCommand } from './reject-kyc.command';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
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 { 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 { ListingRejectedEvent } from '../../../domain/events/listing-rejected.event';
|
||||||
import { RejectListingCommand } from './reject-listing.command';
|
import { RejectListingCommand } from './reject-listing.command';
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
export class UpdateAiSettingsCommand {
|
|
||||||
constructor(
|
|
||||||
public readonly adminId: string,
|
|
||||||
public readonly apiUrl?: string,
|
|
||||||
public readonly apiKey?: string,
|
|
||||||
public readonly model?: string,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
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 { 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 { UserBannedEvent } from '../../../domain/events/user-banned.event';
|
||||||
import { UserUnbannedEvent } from '../../../domain/events/user-unbanned.event';
|
import { UserUnbannedEvent } from '../../../domain/events/user-unbanned.event';
|
||||||
import { UpdateUserStatusCommand } from './update-user-status.command';
|
import { UpdateUserStatusCommand } from './update-user-status.command';
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import {
|
import { type LoggerService } from '@modules/shared';
|
||||||
type EmailChangeRequestedEvent,
|
|
||||||
type EmailChangedEvent,
|
|
||||||
type PhoneChangeRequestedEvent,
|
|
||||||
type PhoneChangedEvent,
|
|
||||||
} from '@modules/auth';
|
|
||||||
import { type ListingOwnershipTransferredEvent } from '@modules/listings';
|
|
||||||
import { LoggerService } from '@modules/shared';
|
|
||||||
import { type KycApprovedEvent } from '../../domain/events/kyc-approved.event';
|
import { type KycApprovedEvent } from '../../domain/events/kyc-approved.event';
|
||||||
import { type KycRejectedEvent } from '../../domain/events/kyc-rejected.event';
|
import { type KycRejectedEvent } from '../../domain/events/kyc-rejected.event';
|
||||||
import { type ListingApprovedEvent } from '../../domain/events/listing-approved.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(
|
private async log(
|
||||||
action: string,
|
action: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
|
|||||||
@@ -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',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { CommandBus } from '@nestjs/cqrs';
|
import { type CommandBus } from '@nestjs/cqrs';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import { SendNotificationCommand } from '@modules/notifications';
|
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';
|
import { type UserBannedEvent } from '../../domain/events/user-banned.event';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import { type UserDeactivatedEvent } from '@modules/auth';
|
import { type UserDeactivatedEvent } from '@modules/auth';
|
||||||
import { LoggerService, PrismaService } from '@modules/shared';
|
import { type LoggerService, type PrismaService } from '@modules/shared';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserDeactivatedListener {
|
export class UserDeactivatedListener {
|
||||||
|
|||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export class GetAiSettingsQuery {
|
|
||||||
constructor() {}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
import { DomainException, LoggerService } from '@modules/shared';
|
import { DomainException, type LoggerService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
AUDIT_LOG_REPOSITORY,
|
AUDIT_LOG_REPOSITORY,
|
||||||
type IAuditLogRepository,
|
type IAuditLogRepository,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
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 { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type DashboardStats } from '../../../domain/repositories/admin-query.repository';
|
||||||
import { GetDashboardStatsQuery } from './get-dashboard-stats.query';
|
import { GetDashboardStatsQuery } from './get-dashboard-stats.query';
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export class GetFlaggedListingsQuery {
|
|
||||||
constructor(
|
|
||||||
public readonly page: number = 1,
|
|
||||||
public readonly limit: number = 20,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
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 { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type KycQueueResult } from '../../../domain/repositories/admin-query.repository';
|
||||||
import { GetKycQueueQuery } from './get-kyc-queue.query';
|
import { GetKycQueueQuery } from './get-kyc-queue.query';
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
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 { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type ModerationQueueResult } from '../../../domain/repositories/admin-query.repository';
|
||||||
import { GetModerationQueueQuery } from './get-moderation-queue.query';
|
import { GetModerationQueueQuery } from './get-moderation-queue.query';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
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 { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type RevenueStatsItem } from '../../../domain/repositories/admin-query.repository';
|
||||||
import { GetRevenueStatsQuery } from './get-revenue-stats.query';
|
import { GetRevenueStatsQuery } from './get-revenue-stats.query';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
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 { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type UserDetail } from '../../../domain/repositories/admin-query.repository';
|
||||||
import { GetUserDetailQuery } from './get-user-detail.query';
|
import { GetUserDetailQuery } from './get-user-detail.query';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
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 { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type UserListResult } from '../../../domain/repositories/admin-query.repository';
|
||||||
import { GetUsersQuery } from './get-users.query';
|
import { GetUsersQuery } from './get-users.query';
|
||||||
|
|
||||||
|
|||||||
@@ -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 { GetKycQueueHandler } from './get-kyc-queue/get-kyc-queue.handler';
|
||||||
export { GetAuditLogsQuery } from './get-audit-logs/get-audit-logs.query';
|
export { GetAuditLogsQuery } from './get-audit-logs/get-audit-logs.query';
|
||||||
export { GetAuditLogsHandler } from './get-audit-logs/get-audit-logs.handler';
|
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';
|
|
||||||
|
|||||||
@@ -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)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,13 +14,3 @@ export {
|
|||||||
type AuditLogListResult,
|
type AuditLogListResult,
|
||||||
type CreateAuditLogInput,
|
type CreateAuditLogInput,
|
||||||
} from './audit-log.repository';
|
} 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';
|
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
export { AdminModule } from './admin.module';
|
export { AdminModule } from './admin.module';
|
||||||
export { SystemSettingsService } from './application/services/system-settings.service';
|
|
||||||
export { ListingApprovedEvent } from './domain/events/listing-approved.event';
|
export { ListingApprovedEvent } from './domain/events/listing-approved.event';
|
||||||
export { ListingRejectedEvent } from './domain/events/listing-rejected.event';
|
export { ListingRejectedEvent } from './domain/events/listing-rejected.event';
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Prisma } from '@prisma/client';
|
|
||||||
import { type PrismaService } from '@modules/shared';
|
import { type PrismaService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
type DashboardStats,
|
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(
|
export async function getRevenueStats(
|
||||||
prisma: PrismaService,
|
prisma: PrismaService,
|
||||||
startDate: Date,
|
startDate: Date,
|
||||||
endDate: Date,
|
endDate: Date,
|
||||||
groupBy: 'day' | 'month',
|
groupBy: 'day' | 'month',
|
||||||
): Promise<RevenueStatsItem[]> {
|
): Promise<RevenueStatsItem[]> {
|
||||||
const cacheKey = buildCacheKey(startDate, endDate, groupBy);
|
const payments = await prisma.payment.findMany({
|
||||||
const cached = revenueStatsCache.get(cacheKey);
|
where: {
|
||||||
if (cached && cached.expiresAt > Date.now()) {
|
status: 'COMPLETED',
|
||||||
return cached.data;
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Postgres can't prove that `DATE_TRUNC($n, ...)` in SELECT and in GROUP BY
|
const stats = grouped.get(period)!;
|
||||||
// are the same expression when the first argument is a bind parameter — it
|
stats.totalRevenue += payment.amountVND;
|
||||||
// raises "column must appear in the GROUP BY clause" (42803). Inline the
|
stats.transactionCount++;
|
||||||
// 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[]>`
|
switch (payment.type) {
|
||||||
SELECT
|
case 'SUBSCRIPTION':
|
||||||
TO_CHAR(DATE_TRUNC(${truncUnit}, "createdAt"), 'YYYY-MM-DD') AS period,
|
stats.subscriptionRevenue += payment.amountVND;
|
||||||
SUM("amountVND") AS total_revenue,
|
break;
|
||||||
SUM(CASE WHEN type = 'SUBSCRIPTION' THEN "amountVND" ELSE 0 END) AS subscription_revenue,
|
case 'LISTING_FEE':
|
||||||
SUM(CASE WHEN type = 'LISTING_FEE' THEN "amountVND" ELSE 0 END) AS listing_fee_revenue,
|
stats.listingFeeRevenue += payment.amountVND;
|
||||||
SUM(CASE WHEN type = 'FEATURED_LISTING' THEN "amountVND" ELSE 0 END) AS featured_listing_revenue,
|
break;
|
||||||
COUNT(*) AS transaction_count
|
case 'FEATURED_LISTING':
|
||||||
FROM "Payment"
|
stats.featuredListingRevenue += payment.amountVND;
|
||||||
WHERE status = 'COMPLETED'
|
break;
|
||||||
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) => ({
|
return Array.from(grouped.entries()).map(([period, stats]) => ({
|
||||||
period: row.period,
|
period,
|
||||||
totalRevenue: BigInt(row.total_revenue),
|
...stats,
|
||||||
subscriptionRevenue: BigInt(row.subscription_revenue),
|
|
||||||
listingFeeRevenue: BigInt(row.listing_fee_revenue),
|
|
||||||
featuredListingRevenue: BigInt(row.featured_listing_revenue),
|
|
||||||
transactionCount: Number(row.transaction_count),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
revenueStatsCache.set(cacheKey, { expiresAt: Date.now() + 60_000, data });
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
export { PrismaAdminQueryRepository } from './prisma-admin-query.repository';
|
export { PrismaAdminQueryRepository } from './prisma-admin-query.repository';
|
||||||
export { PrismaAuditLogRepository } from './prisma-audit-log.repository';
|
export { PrismaAuditLogRepository } from './prisma-audit-log.repository';
|
||||||
export { PrismaModerationAuditLogRepository } from './prisma-moderation-audit-log.repository';
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '@modules/shared';
|
import { type PrismaService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
type IAdminQueryRepository,
|
type IAdminQueryRepository,
|
||||||
type ModerationQueueResult,
|
type ModerationQueueResult,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { type AdminAction, type AuditTargetType, type Prisma } from '@prisma/client';
|
import { type AdminAction, type AuditTargetType, type Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '@modules/shared';
|
import { type PrismaService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
type IAuditLogRepository,
|
type IAuditLogRepository,
|
||||||
type AuditLogEntry,
|
type AuditLogEntry,
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,19 +2,13 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Ip,
|
|
||||||
Param,
|
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam, ApiQuery } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||||
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
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 { ApproveKycCommand } from '../../application/commands/approve-kyc/approve-kyc.command';
|
||||||
import { type ApproveKycResult } from '../../application/commands/approve-kyc/approve-kyc.handler';
|
import { type ApproveKycResult } from '../../application/commands/approve-kyc/approve-kyc.handler';
|
||||||
import { ApproveListingCommand } from '../../application/commands/approve-listing/approve-listing.command';
|
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 { type RejectKycResult } from '../../application/commands/reject-kyc/reject-kyc.handler';
|
||||||
import { RejectListingCommand } from '../../application/commands/reject-listing/reject-listing.command';
|
import { RejectListingCommand } from '../../application/commands/reject-listing/reject-listing.command';
|
||||||
import { type RejectListingResult } from '../../application/commands/reject-listing/reject-listing.handler';
|
import { type RejectListingResult } from '../../application/commands/reject-listing/reject-listing.handler';
|
||||||
import type { FlaggedListingsResult } from '../../application/queries/get-flagged-listings/get-flagged-listings.handler';
|
|
||||||
import { GetFlaggedListingsQuery } from '../../application/queries/get-flagged-listings/get-flagged-listings.query';
|
|
||||||
import { GetKycQueueQuery } from '../../application/queries/get-kyc-queue/get-kyc-queue.query';
|
import { GetKycQueueQuery } from '../../application/queries/get-kyc-queue/get-kyc-queue.query';
|
||||||
import { GetModerationQueueQuery } from '../../application/queries/get-moderation-queue/get-moderation-queue.query';
|
import { GetModerationQueueQuery } from '../../application/queries/get-moderation-queue/get-moderation-queue.query';
|
||||||
import {
|
import {
|
||||||
type ModerationQueueResult,
|
type ModerationQueueResult,
|
||||||
type KycQueueResult,
|
type KycQueueResult,
|
||||||
} from '../../domain/repositories/admin-query.repository';
|
} from '../../domain/repositories/admin-query.repository';
|
||||||
import { AdminFeatureListingDto } from '../dto/admin-feature-listing.dto';
|
import { type ApproveKycDto } from '../dto/approve-kyc.dto';
|
||||||
import { ApproveKycDto } from '../dto/approve-kyc.dto';
|
import { type ApproveListingDto } from '../dto/approve-listing.dto';
|
||||||
import { ApproveListingDto } from '../dto/approve-listing.dto';
|
import { type BulkModerateDto } from '../dto/bulk-moderate.dto';
|
||||||
import { BulkModerateDto } from '../dto/bulk-moderate.dto';
|
import { type RejectKycDto } from '../dto/reject-kyc.dto';
|
||||||
import { RejectKycDto } from '../dto/reject-kyc.dto';
|
import { type RejectListingDto } from '../dto/reject-listing.dto';
|
||||||
import { RejectListingDto } from '../dto/reject-listing.dto';
|
|
||||||
|
|
||||||
@ApiTags('admin')
|
@ApiTags('admin')
|
||||||
@ApiBearerAuth('JWT')
|
@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 ──
|
// ── KYC ──
|
||||||
|
|
||||||
@Get('kyc')
|
@Get('kyc')
|
||||||
|
|||||||
@@ -8,22 +8,15 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} 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 { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger';
|
||||||
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
||||||
import { AdjustSubscriptionCommand } from '../../application/commands/adjust-subscription/adjust-subscription.command';
|
import { AdjustSubscriptionCommand } from '../../application/commands/adjust-subscription/adjust-subscription.command';
|
||||||
import { type AdjustSubscriptionResult } from '../../application/commands/adjust-subscription/adjust-subscription.handler';
|
import { type AdjustSubscriptionResult } from '../../application/commands/adjust-subscription/adjust-subscription.handler';
|
||||||
import { BanUserCommand } from '../../application/commands/ban-user/ban-user.command';
|
import { BanUserCommand } from '../../application/commands/ban-user/ban-user.command';
|
||||||
import { type BanUserResult } from '../../application/commands/ban-user/ban-user.handler';
|
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 { UpdateUserStatusCommand } from '../../application/commands/update-user-status/update-user-status.command';
|
||||||
import { type UpdateUserStatusResult } from '../../application/commands/update-user-status/update-user-status.handler';
|
import { type UpdateUserStatusResult } from '../../application/commands/update-user-status/update-user-status.handler';
|
||||||
import { type AiSettingsDto } from '../../application/queries/get-ai-settings/get-ai-settings.handler';
|
|
||||||
import { GetAiSettingsQuery } from '../../application/queries/get-ai-settings/get-ai-settings.query';
|
|
||||||
import { GetAuditLogsQuery } from '../../application/queries/get-audit-logs/get-audit-logs.query';
|
import { GetAuditLogsQuery } from '../../application/queries/get-audit-logs/get-audit-logs.query';
|
||||||
import { GetDashboardStatsQuery } from '../../application/queries/get-dashboard-stats/get-dashboard-stats.query';
|
import { GetDashboardStatsQuery } from '../../application/queries/get-dashboard-stats/get-dashboard-stats.query';
|
||||||
import { GetRevenueStatsQuery } from '../../application/queries/get-revenue-stats/get-revenue-stats.query';
|
import { GetRevenueStatsQuery } from '../../application/queries/get-revenue-stats/get-revenue-stats.query';
|
||||||
@@ -36,15 +29,12 @@ import {
|
|||||||
type UserDetail,
|
type UserDetail,
|
||||||
} from '../../domain/repositories/admin-query.repository';
|
} from '../../domain/repositories/admin-query.repository';
|
||||||
import { type AuditLogListResult } from '../../domain/repositories/audit-log.repository';
|
import { type AuditLogListResult } from '../../domain/repositories/audit-log.repository';
|
||||||
import { AdjustSubscriptionDto } from '../dto/adjust-subscription.dto';
|
import { type AdjustSubscriptionDto } from '../dto/adjust-subscription.dto';
|
||||||
import { BanUserDto } from '../dto/ban-user.dto';
|
import { type BanUserDto } from '../dto/ban-user.dto';
|
||||||
import { GetAuditLogsQueryDto } from '../dto/get-audit-logs-query.dto';
|
import { type GetAuditLogsQueryDto } from '../dto/get-audit-logs-query.dto';
|
||||||
import { GetUsersQueryDto } from '../dto/get-users-query.dto';
|
import { type GetUsersQueryDto } from '../dto/get-users-query.dto';
|
||||||
import { ProvisionDeveloperDto } from '../dto/provision-developer.dto';
|
import { type RevenueStatsDto } from '../dto/revenue-stats.dto';
|
||||||
import { ProvisionParkOperatorDto } from '../dto/provision-park-operator.dto';
|
import { type UpdateUserStatusDto } from '../dto/update-user-status.dto';
|
||||||
import { RevenueStatsDto } from '../dto/revenue-stats.dto';
|
|
||||||
import { UpdateAiSettingsDto } from '../dto/update-ai-settings.dto';
|
|
||||||
import { UpdateUserStatusDto } from '../dto/update-user-status.dto';
|
|
||||||
|
|
||||||
@ApiTags('admin')
|
@ApiTags('admin')
|
||||||
@ApiBearerAuth('JWT')
|
@ApiBearerAuth('JWT')
|
||||||
@@ -138,23 +128,7 @@ export class AdminController {
|
|||||||
|
|
||||||
@Get('dashboard')
|
@Get('dashboard')
|
||||||
@ApiOperation({ summary: 'Get admin dashboard statistics' })
|
@ApiOperation({ summary: 'Get admin dashboard statistics' })
|
||||||
@ApiResponse({
|
@ApiResponse({ status: 200, description: 'Dashboard stats retrieved successfully' })
|
||||||
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: 401, description: 'Unauthorized – missing or invalid JWT' })
|
@ApiResponse({ status: 401, description: 'Unauthorized – missing or invalid JWT' })
|
||||||
@ApiResponse({ status: 403, description: 'Forbidden – requires ADMIN role' })
|
@ApiResponse({ status: 403, description: 'Forbidden – requires ADMIN role' })
|
||||||
async getDashboardStats(): Promise<DashboardStats> {
|
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 ──
|
// ── Audit Logs ──
|
||||||
|
|
||||||
@Get('audit-logs')
|
@Get('audit-logs')
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -9,4 +9,3 @@ export { ApproveKycDto } from './approve-kyc.dto';
|
|||||||
export { RejectKycDto } from './reject-kyc.dto';
|
export { RejectKycDto } from './reject-kyc.dto';
|
||||||
export { BulkModerateDto } from './bulk-moderate.dto';
|
export { BulkModerateDto } from './bulk-moderate.dto';
|
||||||
export { GetAuditLogsQueryDto } from './get-audit-logs-query.dto';
|
export { GetAuditLogsQueryDto } from './get-audit-logs-query.dto';
|
||||||
export { UpdateAiSettingsDto } from './update-ai-settings.dto';
|
|
||||||
|
|||||||
@@ -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[];
|
|
||||||
}
|
|
||||||
@@ -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[];
|
|
||||||
}
|
|
||||||
@@ -1,31 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsDateString, IsIn, IsOptional, registerDecorator, ValidationArguments, ValidationOptions } from 'class-validator';
|
import { IsDateString, IsIn, IsOptional } 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],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RevenueStatsDto {
|
export class RevenueStatsDto {
|
||||||
@ApiProperty({ description: 'Start date (ISO 8601)', example: '2025-01-01' })
|
@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' })
|
@ApiProperty({ description: 'End date (ISO 8601)', example: '2025-12-31' })
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
@MaxDateRangeDays(366, { message: 'Date range must not exceed 366 days' })
|
|
||||||
endDate!: string;
|
endDate!: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Group results by day or month', enum: ['day', 'month'], default: 'month' })
|
@ApiPropertyOptional({ description: 'Group results by day or month', enum: ['day', 'month'], default: 'month' })
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { CqrsModule } from '@nestjs/cqrs';
|
import { CqrsModule } from '@nestjs/cqrs';
|
||||||
import { RecalculateQualityScoreHandler } from './application/commands/recalculate-quality-score/recalculate-quality-score.handler';
|
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 { ReviewEventsListener } from './application/listeners/review-events.listener';
|
||||||
import { GetAgentDashboardHandler } from './application/queries/get-agent-dashboard/get-agent-dashboard.handler';
|
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';
|
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 { PrismaAgentRepository } from './infrastructure/repositories/prisma-agent.repository';
|
||||||
import { AgentsController } from './presentation/controllers/agents.controller';
|
import { AgentsController } from './presentation/controllers/agents.controller';
|
||||||
|
|
||||||
const CommandHandlers = [RecalculateQualityScoreHandler, UpgradeToAgentHandler];
|
const CommandHandlers = [RecalculateQualityScoreHandler];
|
||||||
|
|
||||||
const QueryHandlers = [GetAgentDashboardHandler, GetAgentPublicProfileHandler];
|
const QueryHandlers = [GetAgentDashboardHandler, GetAgentPublicProfileHandler];
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { DomainException, LoggerService } from '@modules/shared';
|
import { DomainException, type LoggerService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
AGENT_REPOSITORY,
|
AGENT_REPOSITORY,
|
||||||
type IAgentRepository,
|
type IAgentRepository,
|
||||||
|
|||||||
@@ -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[],
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -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ý');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { CommandBus } from '@nestjs/cqrs';
|
import { type CommandBus } from '@nestjs/cqrs';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
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';
|
import { RecalculateQualityScoreCommand } from '../commands/recalculate-quality-score/recalculate-quality-score.command';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||||
import { DomainException, NotFoundException, LoggerService } from '@modules/shared';
|
import { DomainException, NotFoundException, type LoggerService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
AGENT_REPOSITORY,
|
AGENT_REPOSITORY,
|
||||||
type AgentDashboardData,
|
type AgentDashboardData,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||||
import { DomainException, LoggerService } from '@modules/shared';
|
import { DomainException, type LoggerService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
AGENT_REPOSITORY,
|
AGENT_REPOSITORY,
|
||||||
type AgentPublicProfileData,
|
type AgentPublicProfileData,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '@modules/shared';
|
import { type PrismaService } from '@modules/shared';
|
||||||
import { AgentEntity } from '../../domain/entities/agent.entity';
|
import { AgentEntity } from '../../domain/entities/agent.entity';
|
||||||
import {
|
import {
|
||||||
type AgentDashboardData,
|
type AgentDashboardData,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Body, Controller, Get, NotFoundException, Param, Post, UseGuards } from '@nestjs/common';
|
import { Controller, Get, NotFoundException, Param, Post, UseGuards } from '@nestjs/common';
|
||||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
@@ -15,12 +15,9 @@ import {
|
|||||||
Roles,
|
Roles,
|
||||||
} from '@modules/auth';
|
} from '@modules/auth';
|
||||||
import { RecalculateQualityScoreCommand } from '../../application/commands/recalculate-quality-score/recalculate-quality-score.command';
|
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 { 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 { GetAgentPublicProfileQuery } from '../../application/queries/get-agent-public-profile/get-agent-public-profile.query';
|
||||||
import { type AgentDashboardData, type AgentPublicProfileData } from '../../domain/repositories/agent.repository';
|
import { type AgentDashboardData, type AgentPublicProfileData } from '../../domain/repositories/agent.repository';
|
||||||
import { UpgradeToAgentDto } from '../dto/upgrade-to-agent.dto';
|
|
||||||
|
|
||||||
@ApiTags('agents')
|
@ApiTags('agents')
|
||||||
@Controller('agents')
|
@Controller('agents')
|
||||||
@@ -62,29 +59,6 @@ export class AgentsController {
|
|||||||
return profile;
|
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')
|
@ApiBearerAuth('JWT')
|
||||||
@ApiOperation({ summary: 'Recalculate quality score (admin/system)' })
|
@ApiOperation({ summary: 'Recalculate quality score (admin/system)' })
|
||||||
@ApiParam({ name: 'agentId', description: 'Agent ID' })
|
@ApiParam({ name: 'agentId', description: 'Agent ID' })
|
||||||
|
|||||||
@@ -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[];
|
|
||||||
}
|
|
||||||
@@ -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 |
|
|
||||||
@@ -1,32 +1,18 @@
|
|||||||
import { forwardRef, Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { CqrsModule } from '@nestjs/cqrs';
|
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 { GenerateReportHandler } from './application/commands/generate-report/generate-report.handler';
|
||||||
import { TrackEventHandler } from './application/commands/track-event/track-event.handler';
|
import { TrackEventHandler } from './application/commands/track-event/track-event.handler';
|
||||||
import { UpdateMarketIndexHandler } from './application/commands/update-market-index/update-market-index.handler';
|
import { UpdateMarketIndexHandler } from './application/commands/update-market-index/update-market-index.handler';
|
||||||
import { ListingCreatedModerationHandler } from './application/event-handlers/listing-created-moderation.handler';
|
import { ListingCreatedModerationHandler } from './application/event-handlers/listing-created-moderation.handler';
|
||||||
import { BatchValuationHandler } from './application/queries/batch-valuation/batch-valuation.handler';
|
import { BatchValuationHandler } from './application/queries/batch-valuation/batch-valuation.handler';
|
||||||
|
import { IndustrialValuationHandler } from './application/queries/industrial-valuation/industrial-valuation.handler';
|
||||||
import { GetDistrictStatsHandler } from './application/queries/get-district-stats/get-district-stats.handler';
|
import { GetDistrictStatsHandler } from './application/queries/get-district-stats/get-district-stats.handler';
|
||||||
import { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap.handler';
|
import { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap.handler';
|
||||||
import { GetListingAiAdviceHandler } from './application/queries/get-listing-ai-advice/get-listing-ai-advice.handler';
|
|
||||||
import { 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 { 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 { GetNeighborhoodScoreHandler } from './application/queries/get-neighborhood-score/get-neighborhood-score.handler';
|
||||||
import { GetPriceMoversHandler } from './application/queries/get-price-movers/get-price-movers.handler';
|
|
||||||
import { GetPriceTrendHandler } from './application/queries/get-price-trend/get-price-trend.handler';
|
import { GetPriceTrendHandler } from './application/queries/get-price-trend/get-price-trend.handler';
|
||||||
import { GetProjectAiAdviceHandler } from './application/queries/get-project-ai-advice/get-project-ai-advice.handler';
|
|
||||||
import { GetTrendingAreasHandler } from './application/queries/get-trending-areas/get-trending-areas.handler';
|
|
||||||
import { GetValuationHandler } from './application/queries/get-valuation/get-valuation.handler';
|
import { GetValuationHandler } from './application/queries/get-valuation/get-valuation.handler';
|
||||||
import { IndustrialValuationHandler } from './application/queries/industrial-valuation/industrial-valuation.handler';
|
|
||||||
import { PredictValuationHandler } from './application/queries/predict-valuation/predict-valuation.handler';
|
|
||||||
import { ValuationComparisonHandler } from './application/queries/valuation-comparison/valuation-comparison.handler';
|
import { ValuationComparisonHandler } from './application/queries/valuation-comparison/valuation-comparison.handler';
|
||||||
import { ValuationExplanationHandler } from './application/queries/valuation-explanation/valuation-explanation.handler';
|
|
||||||
import { ValuationHistoryHandler } from './application/queries/valuation-history/valuation-history.handler';
|
import { ValuationHistoryHandler } from './application/queries/valuation-history/valuation-history.handler';
|
||||||
import { MARKET_INDEX_REPOSITORY } from './domain/repositories/market-index.repository';
|
import { MARKET_INDEX_REPOSITORY } from './domain/repositories/market-index.repository';
|
||||||
import { VALUATION_REPOSITORY } from './domain/repositories/valuation.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 { AI_SERVICE_CLIENT, AiServiceClient } from './infrastructure/services/ai-service.client';
|
||||||
import { HttpAVMService } from './infrastructure/services/http-avm.service';
|
import { HttpAVMService } from './infrastructure/services/http-avm.service';
|
||||||
import { MarketIndexCronService } from './infrastructure/services/market-index-cron.service';
|
import { MarketIndexCronService } from './infrastructure/services/market-index-cron.service';
|
||||||
import {
|
import { NeighborhoodScoreServiceImpl } from './infrastructure/services/neighborhood-score.service';
|
||||||
HttpNeighborhoodScoreService,
|
|
||||||
PrismaNeighborhoodScoreService,
|
|
||||||
} from './infrastructure/services/neighborhood-score.service';
|
|
||||||
import { PrismaAVMService } from './infrastructure/services/prisma-avm.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 { AnalyticsController } from './presentation/controllers/analytics.controller';
|
||||||
import { AvmController } from './presentation/controllers/avm.controller';
|
import { AvmController } from './presentation/controllers/avm.controller';
|
||||||
|
|
||||||
@@ -59,25 +36,15 @@ const CommandHandlers = [
|
|||||||
|
|
||||||
const QueryHandlers = [
|
const QueryHandlers = [
|
||||||
GetMarketReportHandler,
|
GetMarketReportHandler,
|
||||||
GetMarketHistoryHandler,
|
|
||||||
GetHeatmapHandler,
|
GetHeatmapHandler,
|
||||||
GetListingVolumeWardHandler,
|
|
||||||
GetPriceTrendHandler,
|
GetPriceTrendHandler,
|
||||||
GetDistrictStatsHandler,
|
GetDistrictStatsHandler,
|
||||||
GetValuationHandler,
|
GetValuationHandler,
|
||||||
PredictValuationHandler,
|
|
||||||
BatchValuationHandler,
|
BatchValuationHandler,
|
||||||
ValuationHistoryHandler,
|
ValuationHistoryHandler,
|
||||||
ValuationComparisonHandler,
|
ValuationComparisonHandler,
|
||||||
ValuationExplanationHandler,
|
|
||||||
GetNeighborhoodScoreHandler,
|
GetNeighborhoodScoreHandler,
|
||||||
GetNearbyPOIsHandler,
|
|
||||||
IndustrialValuationHandler,
|
IndustrialValuationHandler,
|
||||||
GetListingAiAdviceHandler,
|
|
||||||
GetProjectAiAdviceHandler,
|
|
||||||
GetMarketSnapshotHandler,
|
|
||||||
GetPriceMoversHandler,
|
|
||||||
GetTrendingAreasHandler,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const EventHandlers = [
|
const EventHandlers = [
|
||||||
@@ -85,12 +52,7 @@ const EventHandlers = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [CqrsModule],
|
||||||
CqrsModule,
|
|
||||||
forwardRef(() => ListingsModule),
|
|
||||||
ProjectsModule,
|
|
||||||
forwardRef(() => AdminModule), // for AI_CONFIG_PROVIDER (used by AI advice handlers)
|
|
||||||
],
|
|
||||||
controllers: [AnalyticsController, AvmController],
|
controllers: [AnalyticsController, AvmController],
|
||||||
providers: [
|
providers: [
|
||||||
// AI service client
|
// AI service client
|
||||||
@@ -104,31 +66,11 @@ const EventHandlers = [
|
|||||||
PrismaAVMService,
|
PrismaAVMService,
|
||||||
{ provide: AVM_SERVICE, useClass: HttpAVMService },
|
{ provide: AVM_SERVICE, useClass: HttpAVMService },
|
||||||
|
|
||||||
// Neighborhood scoring: HTTP proxy → Python AI service, falls back to Prisma scoring
|
// Neighborhood scoring
|
||||||
PrismaNeighborhoodScoreService,
|
{ provide: NEIGHBORHOOD_SCORE_SERVICE, useClass: NeighborhoodScoreServiceImpl },
|
||||||
{ provide: NEIGHBORHOOD_SCORE_SERVICE, useClass: HttpNeighborhoodScoreService },
|
|
||||||
|
|
||||||
// Cron
|
// Cron
|
||||||
MarketIndexCronService,
|
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
|
// CQRS
|
||||||
...CommandHandlers,
|
...CommandHandlers,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { InternalServerErrorException } from '@nestjs/common';
|
import { InternalServerErrorException } from '@nestjs/common';
|
||||||
import { DomainException } from '@modules/shared';
|
|
||||||
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||||
|
import { DomainException } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
type IAVMService,
|
type IAVMService,
|
||||||
type BatchValuationResult,
|
type BatchValuationResult,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -15,13 +15,11 @@ describe('GetHeatmapHandler', () => {
|
|||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
getMarketReport: vi.fn(),
|
getMarketReport: vi.fn(),
|
||||||
getHeatmap: vi.fn(),
|
getHeatmap: vi.fn(),
|
||||||
getHeatmapWard: vi.fn(),
|
|
||||||
getListingVolumeByWard: vi.fn(),
|
|
||||||
getPriceTrend: vi.fn(),
|
getPriceTrend: vi.fn(),
|
||||||
getDistrictStats: vi.fn(),
|
getDistrictStats: vi.fn(),
|
||||||
};
|
};
|
||||||
const mockCache = { getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()) } as unknown as CacheService;
|
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 () => {
|
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.city).toBe('Hồ Chí Minh');
|
||||||
expect(result.period).toBe('2026-Q1');
|
expect(result.period).toBe('2026-Q1');
|
||||||
expect(result.level).toBe('district');
|
|
||||||
expect(result.dataPoints).toEqual(dataPoints);
|
expect(result.dataPoints).toEqual(dataPoints);
|
||||||
expect(mockRepo.getHeatmap).toHaveBeenCalledWith('Hồ Chí Minh', '2026-Q1');
|
expect(mockRepo.getHeatmap).toHaveBeenCalledWith('Hồ Chí Minh', '2026-Q1');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 { type INeighborhoodScoreService, type NeighborhoodScoreResult } from '../../domain/services/neighborhood-score.service';
|
||||||
import { GetNeighborhoodScoreHandler } from '../queries/get-neighborhood-score/get-neighborhood-score.handler';
|
import { GetNeighborhoodScoreHandler } from '../queries/get-neighborhood-score/get-neighborhood-score.handler';
|
||||||
import { GetNeighborhoodScoreQuery } from '../queries/get-neighborhood-score/get-neighborhood-score.query';
|
import { GetNeighborhoodScoreQuery } from '../queries/get-neighborhood-score/get-neighborhood-score.query';
|
||||||
@@ -20,21 +19,13 @@ const sampleScore: NeighborhoodScoreResult = {
|
|||||||
describe('GetNeighborhoodScoreHandler', () => {
|
describe('GetNeighborhoodScoreHandler', () => {
|
||||||
let handler: GetNeighborhoodScoreHandler;
|
let handler: GetNeighborhoodScoreHandler;
|
||||||
let mockService: { [K in keyof INeighborhoodScoreService]: ReturnType<typeof vi.fn> };
|
let mockService: { [K in keyof INeighborhoodScoreService]: ReturnType<typeof vi.fn> };
|
||||||
let mockCache: { getOrSet: ReturnType<typeof vi.fn> };
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockService = {
|
mockService = {
|
||||||
getScore: vi.fn(),
|
getScore: vi.fn(),
|
||||||
calculateAndSave: vi.fn(),
|
calculateAndSave: vi.fn(),
|
||||||
};
|
};
|
||||||
// Bypass cache: call the loader directly
|
handler = new GetNeighborhoodScoreHandler(mockService as any);
|
||||||
mockCache = {
|
|
||||||
getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()),
|
|
||||||
};
|
|
||||||
handler = new GetNeighborhoodScoreHandler(
|
|
||||||
mockService as any,
|
|
||||||
mockCache as unknown as CacheService,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns cached score when available', async () => {
|
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.getScore).toHaveBeenCalledWith('Quận 2', 'Hồ Chí Minh');
|
||||||
expect(mockService.calculateAndSave).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',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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.',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { InternalServerErrorException } from '@nestjs/common';
|
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 { type IAVMService, type ValuationResult } from '../../domain/services/avm-service';
|
||||||
import { ValuationComparisonHandler } from '../queries/valuation-comparison/valuation-comparison.handler';
|
import { ValuationComparisonHandler } from '../queries/valuation-comparison/valuation-comparison.handler';
|
||||||
import { ValuationComparisonQuery } from '../queries/valuation-comparison/valuation-comparison.query';
|
import { ValuationComparisonQuery } from '../queries/valuation-comparison/valuation-comparison.query';
|
||||||
|
|||||||
@@ -1,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
Reference in New Issue
Block a user