Compare commits
7 Commits
master
...
f5da1d9f01
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5da1d9f01 | ||
|
|
41e855e11e | ||
|
|
0cd3dc82fd | ||
|
|
632efbe2c6 | ||
|
|
4ee01294a9 | ||
|
|
b6a5a2c1f5 | ||
|
|
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_URL=redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Redis — Queue (BullMQ)
|
||||
#
|
||||
# RFC-004 Phase 3: the async backbone (BullMQ) can point at a Redis instance
|
||||
# separate from cache / throttler / websocket to keep hot cache traffic from
|
||||
# starving queue operations. If unset, queue traffic falls back to the cache
|
||||
# REDIS_* vars above (single-instance dev and small deployments keep working
|
||||
# unchanged).
|
||||
# -----------------------------------------------------------------------------
|
||||
# REDIS_QUEUE_HOST=
|
||||
# REDIS_QUEUE_PORT=
|
||||
# REDIS_QUEUE_PASSWORD=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Typesense
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -91,15 +78,6 @@ JWT_EXPIRES_IN=15m
|
||||
JWT_REFRESH_SECRET=<generate with: openssl rand -base64 48>
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Seed / E2E Accounts
|
||||
# -----------------------------------------------------------------------------
|
||||
# Required when running `pnpm db:seed`. Use a local/test-only value.
|
||||
# Do not reuse this password for any real production admin account.
|
||||
SEED_DEFAULT_PASSWORD=
|
||||
BCRYPT_ROUNDS=12
|
||||
E2E_ADMIN_PHONE=0876677771
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OAuth Providers
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -119,19 +97,11 @@ FRONTEND_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3000
|
||||
WEB_PORT=3001
|
||||
|
||||
# Demo accounts must stay disabled in production. To enable in a local demo,
|
||||
# provide a JSON array of {phone,name,role,badgeClass} and a temporary password.
|
||||
NEXT_PUBLIC_ENABLE_DEMO_ACCOUNTS=false
|
||||
NEXT_PUBLIC_DEMO_PASSWORD=
|
||||
NEXT_PUBLIC_DEMO_ACCOUNTS=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# AI Service (Python/FastAPI)
|
||||
# -----------------------------------------------------------------------------
|
||||
AI_SERVICE_PORT=8000
|
||||
AI_SERVICE_URL=http://localhost:8000
|
||||
AI_SERVICE_API_KEY=<optional-in-dev-required-in-prod>
|
||||
AI_CORS_ORIGINS=http://localhost:3000,http://localhost:3001
|
||||
CLAUDE_API_KEY=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -141,47 +111,23 @@ NEXT_PUBLIC_MAPBOX_TOKEN=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Payment Gateways (VNPay, MoMo, ZaloPay)
|
||||
# Leave empty if not using payment features.
|
||||
#
|
||||
# IMPORTANT: The values below default to SANDBOX endpoints. When deploying
|
||||
# with NODE_ENV=production, swap each *_BASE_URL / *_ENDPOINT to the
|
||||
# production URL and set *_TMN_CODE / *_PARTNER_CODE / *_APP_ID / secret
|
||||
# values to live merchant credentials issued by the gateway. See
|
||||
# docs/payment-go-live-checklist.md for the full cutover procedure.
|
||||
# The API logs a startup warning if production mode is detected with
|
||||
# sandbox-looking credentials.
|
||||
# Leave empty if not using payment features
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# VNPay — sandbox by default
|
||||
# Production: VNPAY_BASE_URL=https://pay.vnpay.vn/vpcpay.html
|
||||
# Production: VNPAY_API_URL=https://merchant.vnpay.vn/merchant_webapi/api/transaction
|
||||
VNPAY_TMN_CODE=
|
||||
VNPAY_HASH_SECRET=
|
||||
VNPAY_BASE_URL=https://sandbox.vnpayment.vn/paymentv2/vpcpay.html
|
||||
VNPAY_API_URL=https://sandbox.vnpayment.vn/merchant_webapi/api/transaction
|
||||
|
||||
# MoMo — sandbox by default
|
||||
# Production: MOMO_ENDPOINT=https://payment.momo.vn/v2/gateway/api
|
||||
MOMO_PARTNER_CODE=
|
||||
MOMO_ACCESS_KEY=
|
||||
MOMO_SECRET_KEY=
|
||||
MOMO_ENDPOINT=https://test-payment.momo.vn/v2/gateway/api
|
||||
|
||||
# ZaloPay — sandbox by default
|
||||
# Production: ZALOPAY_ENDPOINT=https://openapi.zalopay.vn/v2
|
||||
ZALOPAY_APP_ID=
|
||||
ZALOPAY_KEY1=
|
||||
ZALOPAY_KEY2=
|
||||
ZALOPAY_ENDPOINT=https://sb-openapi.zalopay.vn/v2
|
||||
|
||||
# Backend base URL used to construct IPN (server-to-server) callback URLs for
|
||||
# MoMo (ipnUrl) and ZaloPay (callback_url). Must point to the API server, NOT
|
||||
# the frontend. Example: https://api.goodgo.vn
|
||||
# Individual gateway callback paths are appended automatically:
|
||||
# MoMo → {PAYMENT_CALLBACK_BASE_URL}/api/v1/payments/callback/momo
|
||||
# ZaloPay → {PAYMENT_CALLBACK_BASE_URL}/api/v1/payments/callback/zalopay
|
||||
PAYMENT_CALLBACK_BASE_URL=https://api.goodgo.vn
|
||||
|
||||
BANK_TRANSFER_ACCOUNT_NUMBER=
|
||||
BANK_TRANSFER_BANK_NAME=
|
||||
BANK_TRANSFER_ACCOUNT_HOLDER=
|
||||
@@ -238,10 +184,7 @@ SENTRY_PROJECT=
|
||||
# Must be exactly 64 hex characters (32 bytes).
|
||||
# openssl rand -hex 32
|
||||
# -----------------------------------------------------------------------------
|
||||
FIELD_ENCRYPTION_KEY=<generate with: openssl rand -hex 32>
|
||||
FIELD_ENCRYPTION_KEY_VERSION=1
|
||||
# Backward-compatible fallback accepted by the API; prefer FIELD_ENCRYPTION_KEY.
|
||||
KYC_ENCRYPTION_KEY=
|
||||
KYC_ENCRYPTION_KEY=<generate with: openssl rand -hex 32>
|
||||
KYC_ENCRYPTION_KEY_VERSION=1
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -51,10 +51,6 @@ CORS_ORIGINS=http://localhost:3010,http://localhost:3000
|
||||
# Bcrypt (fast rounds for test — production uses 12+)
|
||||
BCRYPT_ROUNDS=4
|
||||
|
||||
# Seeded admin used by E2E happy-path admin flows
|
||||
SEED_DEFAULT_PASSWORD=Test@1234!
|
||||
E2E_ADMIN_PHONE=0876677771
|
||||
|
||||
# OAuth (test stubs)
|
||||
GOOGLE_CLIENT_ID=test-google-client-id
|
||||
GOOGLE_CLIENT_SECRET=test-google-client-secret
|
||||
@@ -74,8 +70,3 @@ MOMO_SECRET_KEY=TEST_MOMO_SECRET_KEY
|
||||
ZALOPAY_APP_ID=TEST_ZALOPAY_APP
|
||||
ZALOPAY_KEY1=TEST_ZALOPAY_KEY1
|
||||
ZALOPAY_KEY2=TEST_ZALOPAY_KEY2
|
||||
BANK_TRANSFER_ACCOUNT_NUMBER=0123456789
|
||||
BANK_TRANSFER_BANK_NAME=Vietcombank
|
||||
BANK_TRANSFER_ACCOUNT_HOLDER=CONG_TY_GOODGO
|
||||
BANK_TRANSFER_WEBHOOK_SECRET=test-bank-transfer-webhook-secret-minimum-32-chars
|
||||
BANK_TRANSFER_INSTRUCTIONS_URL=http://localhost:3010/thanh-toan/chuyen-khoan
|
||||
|
||||
158
.github/workflows/ci.yml
vendored
158
.github/workflows/ci.yml
vendored
@@ -70,89 +70,83 @@ jobs:
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
ai-services:
|
||||
name: AI Services (Python) — Smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: libs/ai-services
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: pip
|
||||
cache-dependency-path: libs/ai-services/pyproject.toml
|
||||
|
||||
- name: Install dependencies (runtime + dev, no underthesea)
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install \
|
||||
"fastapi==0.115.0" \
|
||||
"uvicorn[standard]==0.32.0" \
|
||||
"xgboost==2.1.0" \
|
||||
"numpy==1.26.4" \
|
||||
"pydantic==2.9.0" \
|
||||
"pydantic-settings==2.5.0" \
|
||||
"httpx==0.27.0" \
|
||||
"slowapi==0.1.9" \
|
||||
"scikit-learn>=1.5.0" \
|
||||
"pytest>=8.3.0" \
|
||||
"pytest-asyncio>=0.24.0"
|
||||
|
||||
- name: Pytest (unit + health smoke)
|
||||
env:
|
||||
AI_CORS_ORIGINS: http://localhost:3000
|
||||
run: pytest -q --ignore=tests/test_nlp.py
|
||||
|
||||
- name: Boot FastAPI + /health smoke
|
||||
env:
|
||||
AI_CORS_ORIGINS: http://localhost:3000
|
||||
run: |
|
||||
uvicorn app.main:app --host 127.0.0.1 --port 8000 &
|
||||
PID=$!
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if curl -sf http://127.0.0.1:8000/health; then
|
||||
echo "health ok"
|
||||
kill $PID
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "health failed"
|
||||
kill $PID || true
|
||||
exit 1
|
||||
|
||||
- name: OpenAPI schema export (verifies /predict routes)
|
||||
env:
|
||||
AI_CORS_ORIGINS: http://localhost:3000
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json, sys
|
||||
from app.main import app
|
||||
schema = app.openapi()
|
||||
paths = schema.get("paths", {})
|
||||
required = ["/avm/predict", "/avm/v2/predict", "/avm/industrial/predict", "/moderation/check", "/neighborhood/score"]
|
||||
missing = [p for p in required if p not in paths]
|
||||
if missing:
|
||||
print("MISSING OpenAPI paths:", missing)
|
||||
sys.exit(1)
|
||||
print("OpenAPI paths OK:", sorted(paths.keys()))
|
||||
PY
|
||||
|
||||
e2e:
|
||||
name: E2E Tests
|
||||
needs: ci
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
timeout-minutes: 20
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgis/postgis:16-3.4
|
||||
env:
|
||||
POSTGRES_DB: goodgo_test
|
||||
POSTGRES_USER: goodgo
|
||||
POSTGRES_PASSWORD: goodgo_test_secret
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U goodgo -d goodgo_test"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
--health-start-period 30s
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
typesense:
|
||||
image: typesense/typesense:27.1
|
||||
ports:
|
||||
- 8108:8108
|
||||
env:
|
||||
TYPESENSE_API_KEY: ts_ci_key
|
||||
TYPESENSE_DATA_DIR: /data
|
||||
options: >-
|
||||
--health-cmd "curl -sf http://localhost:8108/health || exit 1"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
ports:
|
||||
- 9000:9000
|
||||
env:
|
||||
MINIO_ROOT_USER: ci_minio_user
|
||||
MINIO_ROOT_PASSWORD: ci_minio_secret_key_32chars!!
|
||||
options: >-
|
||||
--health-cmd "curl -sf http://localhost:9000/minio/health/live || exit 1"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
env:
|
||||
CI: true
|
||||
DATABASE_URL: postgresql://goodgo:goodgo_test_secret@localhost:5432/goodgo_test
|
||||
REDIS_URL: redis://localhost:6379
|
||||
TYPESENSE_URL: http://localhost:8108
|
||||
TYPESENSE_HOST: localhost
|
||||
TYPESENSE_PORT: 8108
|
||||
TYPESENSE_API_KEY: ts_ci_key
|
||||
MINIO_ENDPOINT: localhost
|
||||
MINIO_PORT: 9000
|
||||
MINIO_ACCESS_KEY: ci_minio_user
|
||||
MINIO_SECRET_KEY: ci_minio_secret_key_32chars!!
|
||||
MINIO_BUCKET: goodgo-uploads
|
||||
NODE_ENV: test
|
||||
JWT_SECRET: e2e-test-jwt-secret-key
|
||||
JWT_REFRESH_SECRET: e2e-test-refresh-secret-key
|
||||
VNPAY_TMN_CODE: TESTCODE
|
||||
VNPAY_HASH_SECRET: TESTHASHSECRET
|
||||
VNPAY_URL: https://sandbox.vnpayment.vn/paymentv2/vpcpay.html
|
||||
VNPAY_RETURN_URL: http://localhost:3000/payment/return
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -170,12 +164,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Load E2E environment
|
||||
run: awk 'NF && $1 !~ /^#/' .env.test >> "$GITHUB_ENV"
|
||||
|
||||
- name: Start CI service stack
|
||||
run: docker compose --env-file .env.ci -f docker-compose.ci.yml up -d --wait
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v4
|
||||
@@ -218,7 +206,3 @@ jobs:
|
||||
name: playwright-traces
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Stop CI service stack
|
||||
if: always()
|
||||
run: docker compose --env-file .env.ci -f docker-compose.ci.yml down -v
|
||||
|
||||
61
.github/workflows/codeql.yml
vendored
Normal file
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
|
||||
193
.github/workflows/deploy.yml
vendored
193
.github/workflows/deploy.yml
vendored
@@ -23,53 +23,6 @@ env:
|
||||
REGISTRY_URL: ghcr.io/${{ github.repository_owner }}
|
||||
|
||||
jobs:
|
||||
deploy-config:
|
||||
name: Check Deploy Configuration
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
staging_ready: ${{ steps.check.outputs.staging_ready }}
|
||||
production_ready: ${{ steps.check.outputs.production_ready }}
|
||||
|
||||
steps:
|
||||
- name: Check required deploy secrets
|
||||
id: check
|
||||
env:
|
||||
TARGET_ENV: ${{ inputs.environment }}
|
||||
STAGING_HOST: ${{ secrets.STAGING_HOST }}
|
||||
STAGING_USER: ${{ secrets.STAGING_USER }}
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
STAGING_URL: ${{ secrets.STAGING_URL }}
|
||||
STAGING_API_URL: ${{ secrets.STAGING_API_URL }}
|
||||
PRODUCTION_HOST: ${{ secrets.PRODUCTION_HOST }}
|
||||
PRODUCTION_USER: ${{ secrets.PRODUCTION_USER }}
|
||||
PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
|
||||
PRODUCTION_URL: ${{ secrets.PRODUCTION_URL }}
|
||||
PRODUCTION_API_URL: ${{ secrets.PRODUCTION_API_URL }}
|
||||
run: |
|
||||
STAGING_READY=false
|
||||
PRODUCTION_READY=false
|
||||
|
||||
if [ -n "$STAGING_HOST" ] && [ -n "$STAGING_USER" ] && [ -n "$STAGING_SSH_KEY" ] && [ -n "$STAGING_URL" ] && [ -n "$STAGING_API_URL" ]; then
|
||||
STAGING_READY=true
|
||||
fi
|
||||
|
||||
if [ -n "$PRODUCTION_HOST" ] && [ -n "$PRODUCTION_USER" ] && [ -n "$PRODUCTION_SSH_KEY" ] && [ -n "$PRODUCTION_URL" ] && [ -n "$PRODUCTION_API_URL" ]; then
|
||||
PRODUCTION_READY=true
|
||||
fi
|
||||
|
||||
echo "staging_ready=$STAGING_READY" >> "$GITHUB_OUTPUT"
|
||||
echo "production_ready=$PRODUCTION_READY" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ "$TARGET_ENV" = "staging" ] && [ "$STAGING_READY" != "true" ]; then
|
||||
echo "Missing required staging deploy secrets; configure STAGING_HOST, STAGING_USER, STAGING_SSH_KEY, STAGING_URL, and STAGING_API_URL."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ "$TARGET_ENV" = "production" ] && [ "$PRODUCTION_READY" != "true" ]; then
|
||||
echo "Missing required production deploy secrets; configure PRODUCTION_HOST, PRODUCTION_USER, PRODUCTION_SSH_KEY, PRODUCTION_URL, and PRODUCTION_API_URL."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-api:
|
||||
name: Build API Image
|
||||
runs-on: ubuntu-latest
|
||||
@@ -201,14 +154,11 @@ jobs:
|
||||
|
||||
deploy-staging:
|
||||
name: Deploy to Staging
|
||||
needs: [deploy-config, build-api, build-web, build-ai]
|
||||
needs: [build-api, build-web, build-ai]
|
||||
if: >-
|
||||
needs.deploy-config.outputs.staging_ready == 'true' &&
|
||||
(
|
||||
github.ref == 'refs/heads/develop' ||
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/master') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.environment == 'staging')
|
||||
)
|
||||
github.ref == 'refs/heads/develop' ||
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/master') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.environment == 'staging')
|
||||
runs-on: ubuntu-latest
|
||||
environment: staging
|
||||
|
||||
@@ -271,17 +221,17 @@ jobs:
|
||||
[ "\$PREV_WEB" != "none" ] && docker tag "\$PREV_WEB" goodgo-web:rollback 2>/dev/null || true
|
||||
[ "\$PREV_AI" != "none" ] && docker tag "\$PREV_AI" goodgo-ai-services:rollback 2>/dev/null || true
|
||||
|
||||
# Pull new images
|
||||
docker compose -f docker-compose.prod.yml pull api web ai-services
|
||||
|
||||
# Apply migrations with the newly pulled API image before switching app containers.
|
||||
docker compose -f docker-compose.prod.yml run --rm --no-deps api \
|
||||
npx prisma migrate deploy --schema /app/prisma/schema.prisma
|
||||
|
||||
# Rolling update — zero downtime
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --wait api
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --wait web
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --wait ai-services
|
||||
|
||||
# Run database migrations
|
||||
docker compose -f docker-compose.prod.yml exec -T api npx prisma migrate deploy
|
||||
|
||||
# NOTE: docker image prune is NOT run here — it runs after smoke tests pass
|
||||
DEPLOY_SCRIPT
|
||||
|
||||
@@ -332,61 +282,13 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run bash smoke tests
|
||||
- name: Run smoke tests
|
||||
env:
|
||||
STAGING_URL: ${{ secrets.STAGING_URL }}
|
||||
run: |
|
||||
chmod +x scripts/smoke-test.sh
|
||||
./scripts/smoke-test.sh "$STAGING_URL"
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Install Playwright system deps
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
run: npx playwright install-deps chromium
|
||||
|
||||
- name: Run Playwright smoke tests (API)
|
||||
env:
|
||||
API_BASE_URL: ${{ secrets.STAGING_API_URL }}
|
||||
CI: true
|
||||
run: npx playwright test --project=smoke-api
|
||||
|
||||
- name: Run Playwright smoke tests (Web)
|
||||
env:
|
||||
API_BASE_URL: ${{ secrets.STAGING_API_URL }}
|
||||
WEB_BASE_URL: ${{ secrets.STAGING_URL }}
|
||||
CI: true
|
||||
run: npx playwright test --project=smoke-web
|
||||
|
||||
- name: Upload Playwright smoke report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: smoke-report-staging-${{ github.run_id }}
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
- name: Cleanup old images after successful smoke tests
|
||||
if: success()
|
||||
env:
|
||||
@@ -444,11 +346,8 @@ jobs:
|
||||
|
||||
rollback-staging:
|
||||
name: Rollback Staging
|
||||
needs: [deploy-config, deploy-staging, smoke-test-staging]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.deploy-config.outputs.staging_ready == 'true' &&
|
||||
(needs.deploy-staging.result == 'failure' || needs.smoke-test-staging.result == 'failure')
|
||||
needs: [deploy-staging, smoke-test-staging]
|
||||
if: failure()
|
||||
runs-on: ubuntu-latest
|
||||
environment: staging
|
||||
|
||||
@@ -515,11 +414,8 @@ jobs:
|
||||
|
||||
deploy-production:
|
||||
name: Deploy to Production
|
||||
needs: [deploy-config, build-api, build-web, build-ai]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' &&
|
||||
inputs.environment == 'production' &&
|
||||
needs.deploy-config.outputs.production_ready == 'true'
|
||||
needs: [build-api, build-web, build-ai]
|
||||
if: inputs.environment == 'production'
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
|
||||
@@ -563,15 +459,13 @@ jobs:
|
||||
|
||||
docker compose -f docker-compose.prod.yml pull api web ai-services
|
||||
|
||||
# Apply migrations with the newly pulled API image before switching app containers.
|
||||
docker compose -f docker-compose.prod.yml run --rm --no-deps api \
|
||||
npx prisma migrate deploy --schema /app/prisma/schema.prisma
|
||||
|
||||
# Rolling update with health checks
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --wait api
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --wait web
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --wait ai-services
|
||||
|
||||
docker compose -f docker-compose.prod.yml exec -T api npx prisma migrate deploy
|
||||
|
||||
# NOTE: docker image prune is NOT run here — it runs after smoke tests pass
|
||||
DEPLOY_SCRIPT
|
||||
|
||||
@@ -616,61 +510,13 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run bash smoke tests
|
||||
- name: Run smoke tests
|
||||
env:
|
||||
PRODUCTION_URL: ${{ secrets.PRODUCTION_URL }}
|
||||
run: |
|
||||
chmod +x scripts/smoke-test.sh
|
||||
./scripts/smoke-test.sh "$PRODUCTION_URL"
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Install Playwright system deps
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
run: npx playwright install-deps chromium
|
||||
|
||||
- name: Run Playwright smoke tests (API)
|
||||
env:
|
||||
API_BASE_URL: ${{ secrets.PRODUCTION_API_URL }}
|
||||
CI: true
|
||||
run: npx playwright test --project=smoke-api
|
||||
|
||||
- name: Run Playwright smoke tests (Web)
|
||||
env:
|
||||
API_BASE_URL: ${{ secrets.PRODUCTION_API_URL }}
|
||||
WEB_BASE_URL: ${{ secrets.PRODUCTION_URL }}
|
||||
CI: true
|
||||
run: npx playwright test --project=smoke-web
|
||||
|
||||
- name: Upload Playwright smoke report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: smoke-report-production-${{ github.run_id }}
|
||||
path: playwright-report/
|
||||
retention-days: 14
|
||||
|
||||
- name: Cleanup old images after successful smoke tests
|
||||
if: success()
|
||||
env:
|
||||
@@ -710,11 +556,8 @@ jobs:
|
||||
|
||||
rollback-production:
|
||||
name: Rollback Production
|
||||
needs: [deploy-config, deploy-production, smoke-test-production]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.deploy-config.outputs.production_ready == 'true' &&
|
||||
(needs.deploy-production.result == 'failure' || needs.smoke-test-production.result == 'failure')
|
||||
needs: [smoke-test-production]
|
||||
if: failure()
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
|
||||
|
||||
100
.github/workflows/e2e.yml
vendored
100
.github/workflows/e2e.yml
vendored
@@ -14,10 +14,98 @@ jobs:
|
||||
e2e:
|
||||
name: Playwright E2E
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
timeout-minutes: 20
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgis/postgis:16-3.4
|
||||
env:
|
||||
POSTGRES_DB: goodgo_test
|
||||
POSTGRES_USER: goodgo
|
||||
POSTGRES_PASSWORD: goodgo_test_secret
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U goodgo -d goodgo_test"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
--health-start-period 30s
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
typesense:
|
||||
image: typesense/typesense:27.1
|
||||
ports:
|
||||
- 8108:8108
|
||||
env:
|
||||
TYPESENSE_API_KEY: ts_ci_key
|
||||
TYPESENSE_DATA_DIR: /data
|
||||
options: >-
|
||||
--health-cmd "curl -sf http://localhost:8108/health || exit 1"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
ports:
|
||||
- 9000:9000
|
||||
env:
|
||||
MINIO_ROOT_USER: ${{ vars.CI_MINIO_ACCESS_KEY || 'ci_minio_user' }}
|
||||
MINIO_ROOT_PASSWORD: ${{ vars.CI_MINIO_SECRET_KEY || 'ci_minio_secret_key_32chars!!' }}
|
||||
options: >-
|
||||
--health-cmd "curl -sf http://localhost:9000/minio/health/live || exit 1"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://goodgo:goodgo_test_secret@localhost:5432/goodgo_test
|
||||
REDIS_URL: redis://localhost:6379
|
||||
REDIS_HOST: localhost
|
||||
REDIS_PORT: 6379
|
||||
TYPESENSE_URL: http://localhost:8108
|
||||
TYPESENSE_HOST: localhost
|
||||
TYPESENSE_PORT: 8108
|
||||
TYPESENSE_PROTOCOL: http
|
||||
TYPESENSE_API_KEY: ts_ci_key
|
||||
MINIO_ENDPOINT: localhost
|
||||
MINIO_PORT: 9000
|
||||
MINIO_ACCESS_KEY: ${{ vars.CI_MINIO_ACCESS_KEY || 'ci_minio_user' }}
|
||||
MINIO_SECRET_KEY: ${{ vars.CI_MINIO_SECRET_KEY || 'ci_minio_secret_key_32chars!!' }}
|
||||
MINIO_BUCKET: goodgo-uploads
|
||||
NODE_ENV: test
|
||||
CI: true
|
||||
# API and Web ports for Playwright webServer
|
||||
API_PORT: 3001
|
||||
WEB_PORT: 3000
|
||||
API_BASE_URL: http://localhost:3001/api/v1/
|
||||
WEB_BASE_URL: http://localhost:3000
|
||||
NEXT_PUBLIC_API_URL: http://localhost:3001/api/v1
|
||||
JWT_SECRET: e2e-test-jwt-secret-key-minimum-32-chars-long-enough
|
||||
JWT_REFRESH_SECRET: e2e-test-refresh-secret-key-minimum-32-chars-ok
|
||||
JWT_EXPIRES_IN: 15m
|
||||
JWT_REFRESH_EXPIRES_IN: 7d
|
||||
BCRYPT_ROUNDS: 4
|
||||
VNPAY_TMN_CODE: TESTCODE
|
||||
VNPAY_HASH_SECRET: TESTHASHSECRETTESTHASHSECRETTEST
|
||||
VNPAY_URL: https://sandbox.vnpayment.vn/paymentv2/vpcpay.html
|
||||
VNPAY_RETURN_URL: http://localhost:3000/payment/return
|
||||
GOOGLE_CLIENT_ID: test-google-client-id
|
||||
GOOGLE_CLIENT_SECRET: test-google-client-secret
|
||||
GOOGLE_CALLBACK_URL: http://localhost:3001/api/v1/auth/google/callback
|
||||
ZALO_APP_ID: test-zalo-app-id
|
||||
ZALO_APP_SECRET: test-zalo-app-secret
|
||||
ZALO_CALLBACK_URL: http://localhost:3001/api/v1/auth/zalo/callback
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -35,12 +123,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Load E2E environment
|
||||
run: awk 'NF && $1 !~ /^#/' .env.test >> "$GITHUB_ENV"
|
||||
|
||||
- name: Start CI service stack
|
||||
run: docker compose --env-file .env.ci -f docker-compose.ci.yml up -d --wait
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v4
|
||||
@@ -83,7 +165,3 @@ jobs:
|
||||
name: playwright-traces
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Stop CI service stack
|
||||
if: always()
|
||||
run: docker compose --env-file .env.ci -f docker-compose.ci.yml down -v
|
||||
|
||||
76
.github/workflows/security.yml
vendored
76
.github/workflows/security.yml
vendored
@@ -15,6 +15,7 @@ concurrency:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
# ── Dependency Audit ─────────────────────────────────────────────
|
||||
@@ -95,8 +96,25 @@ jobs:
|
||||
cache-from: type=gha,scope=api-scan
|
||||
cache-to: type=gha,mode=max,scope=api-scan
|
||||
|
||||
- name: Run Trivy vulnerability scanner (API)
|
||||
uses: aquasecurity/trivy-action@0.28.0
|
||||
with:
|
||||
image-ref: "goodgo-api:scan"
|
||||
format: "sarif"
|
||||
output: "trivy-api-results.sarif"
|
||||
severity: "CRITICAL,HIGH"
|
||||
# Ignore unfixed vulns to reduce noise
|
||||
ignore-unfixed: true
|
||||
|
||||
- name: Upload Trivy SARIF (API)
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: "trivy-api-results.sarif"
|
||||
category: "trivy-api"
|
||||
|
||||
- name: Trivy table output (API)
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
uses: aquasecurity/trivy-action@0.28.0
|
||||
with:
|
||||
image-ref: "goodgo-api:scan"
|
||||
format: "table"
|
||||
@@ -126,8 +144,24 @@ jobs:
|
||||
cache-from: type=gha,scope=web-scan
|
||||
cache-to: type=gha,mode=max,scope=web-scan
|
||||
|
||||
- name: Run Trivy vulnerability scanner (Web)
|
||||
uses: aquasecurity/trivy-action@0.28.0
|
||||
with:
|
||||
image-ref: "goodgo-web:scan"
|
||||
format: "sarif"
|
||||
output: "trivy-web-results.sarif"
|
||||
severity: "CRITICAL,HIGH"
|
||||
ignore-unfixed: true
|
||||
|
||||
- name: Upload Trivy SARIF (Web)
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: "trivy-web-results.sarif"
|
||||
category: "trivy-web"
|
||||
|
||||
- name: Trivy table output (Web)
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
uses: aquasecurity/trivy-action@0.28.0
|
||||
with:
|
||||
image-ref: "goodgo-web:scan"
|
||||
format: "table"
|
||||
@@ -157,8 +191,24 @@ jobs:
|
||||
cache-from: type=gha,scope=ai-scan
|
||||
cache-to: type=gha,mode=max,scope=ai-scan
|
||||
|
||||
- name: Run Trivy vulnerability scanner (AI)
|
||||
uses: aquasecurity/trivy-action@0.28.0
|
||||
with:
|
||||
image-ref: "goodgo-ai:scan"
|
||||
format: "sarif"
|
||||
output: "trivy-ai-results.sarif"
|
||||
severity: "CRITICAL,HIGH"
|
||||
ignore-unfixed: true
|
||||
|
||||
- name: Upload Trivy SARIF (AI)
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: "trivy-ai-results.sarif"
|
||||
category: "trivy-ai"
|
||||
|
||||
- name: Trivy table output (AI)
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
uses: aquasecurity/trivy-action@0.28.0
|
||||
with:
|
||||
image-ref: "goodgo-ai:scan"
|
||||
format: "table"
|
||||
@@ -175,8 +225,26 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run Trivy filesystem scanner
|
||||
uses: aquasecurity/trivy-action@0.28.0
|
||||
with:
|
||||
scan-type: "fs"
|
||||
scan-ref: "."
|
||||
format: "sarif"
|
||||
output: "trivy-fs-results.sarif"
|
||||
severity: "CRITICAL,HIGH"
|
||||
ignore-unfixed: true
|
||||
scanners: "vuln,secret,misconfig"
|
||||
|
||||
- name: Upload Trivy SARIF (filesystem)
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: "trivy-fs-results.sarif"
|
||||
category: "trivy-filesystem"
|
||||
|
||||
- name: Trivy filesystem table output
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
uses: aquasecurity/trivy-action@0.28.0
|
||||
with:
|
||||
scan-type: "fs"
|
||||
scan-ref: "."
|
||||
|
||||
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
|
||||
npm-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/),
|
||||
và dự án này tuân theo [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### GOO-33 Documentation Sprint (2026-04-22)
|
||||
### Added (CEO Audit Wave 13 — 2026-04-12)
|
||||
- CEO audit routine (TEC-1915) — full codebase audit + project state review
|
||||
- Plan document with 7-section report: audit summary, critical issues, priorities, recommendations
|
||||
- 6 new subtasks created (TEC-1918 through TEC-1923) for Wave 13
|
||||
- Updated PROJECT_TRACKER with Wave 13 tracking section
|
||||
|
||||
#### Đã hoàn thành
|
||||
- QA_TRACKER.md — cập nhật test status baseline + Sprint 1-2 test plans
|
||||
- CHANGELOG.md — cập nhật changelog lần cuối (2026-04-22)
|
||||
- PROJECT_TRACKER.md — cập nhật GOO-33 status → in_progress
|
||||
- CONTRIBUTING.md — thêm branching strategy, PR conventions, commit message format
|
||||
- docs/ci-cd.md — tài liệu đầy đủ GitHub Actions pipeline (lint → typecheck → test → build)
|
||||
- docs/onboarding.md — hướng dẫn setup dành cho developer mới
|
||||
- docs/mcp-servers.md — tài liệu 3 MCP servers (search, analytics, valuation)
|
||||
- docs/audits/ — curate từ ~103 → 12 canonical audit reports + archive old files
|
||||
### QA Results (2026-04-12)
|
||||
- Lint: PASS (0 errors)
|
||||
- TypeScript: 7 errors in web test files (vitest types missing) — TEC-1918
|
||||
- Unit Tests: 232 files, 1454 tests, ALL PASS
|
||||
- Build: ALL 3 packages build successfully
|
||||
- Git: Clean working tree
|
||||
|
||||
### GOO-2 Lead Orchestrator Audit (2026-04-22)
|
||||
### Added
|
||||
- CEO full audit & implementation plan (TEC-1882) — 8-part report covering architecture, quality, security
|
||||
- 7 new subtasks created (TEC-1888 through TEC-1894) for Wave 11D-13
|
||||
- Updated PROJECT_TRACKER with Waves 11D-13 subtask tracking
|
||||
- Updated QA_TRACKER with 2026-04-11 test report (27 failing tests identified)
|
||||
- Comprehensive audit reports: AUDIT_SUMMARY, COMPREHENSIVE_AUDIT, AUDIT_INDEX
|
||||
|
||||
#### Audit & Planning
|
||||
- Kiểm tra toàn diện codebase: 51 findings (4 blockers, 24 high, 13 medium, 10 low)
|
||||
- Nghiên cứu thị trường BĐS VN: 23 findings (3 P0, 10 P1, 8 P2, 1 P3)
|
||||
- Ma trận đề xuất: 25 cải thiện (Nhóm A) + 20 tính năng mới (Nhóm B) + 10 docs gaps (Nhóm C)
|
||||
- Tạo 32 subtasks (GOO-3 → GOO-34) phân theo 6 sprints
|
||||
- Tạo QA_TRACKER.md, cập nhật PROJECT_TRACKER.md
|
||||
### Identified (from CEO Audit 2026-04-11)
|
||||
- 725 ESLint errors (712 auto-fixable) — TEC-1888
|
||||
- TypeScript errors in web tests (json-ld.spec.tsx) — TEC-1888
|
||||
- 27 failing rate limit guard tests — TEC-1889
|
||||
- 3 incomplete API modules (health, metrics, mcp) — TEC-1890
|
||||
- MCP servers are stubs (~50 lines each) — TEC-1891
|
||||
- Only 6 web unit tests (need 50+) — TEC-1892
|
||||
- No field-level PII encryption — TEC-1893
|
||||
- No MFA for agent/admin accounts — TEC-1894
|
||||
|
||||
#### Đã sửa
|
||||
- GOO-3: Fix double CSRF middleware — login/register/payment callbacks hoạt động (Sprint 1) ✅
|
||||
### Previously Added
|
||||
- CEO audit plan document with full improvement & feature matrix (TEC-1682)
|
||||
- Wave 5 issues: npm vulnerability fixes, test coverage, Saved Searches, Dependabot
|
||||
- PgBouncer connection pooling for production PostgreSQL
|
||||
- SEO optimization — JSON-LD, dynamic sitemap, meta tags for listings
|
||||
- API error codes reference documentation
|
||||
- Security headers hardening across API and Web apps
|
||||
- Multi-stage production Dockerfile for NestJS API
|
||||
- Startup-time validation for JWT secrets (rejects placeholders)
|
||||
- Per-type file size limits and 413 responses for media uploads
|
||||
- Rate limiting and auth guard for MCP transport controller
|
||||
- Async error handling for critical module handlers
|
||||
- QueryErrorBoundary component with real map coordinates (web)
|
||||
- GDPR-compliant user data deletion endpoint
|
||||
- Listing search caching with @Cacheable decorator
|
||||
- Auth + search i18n translations and filter-bar accessibility
|
||||
|
||||
#### Đang triển khai (Sprint 1 Blockers)
|
||||
- GOO-4: UsageRecord atomic metering (fix quota bypass)
|
||||
- GOO-5: Rate-limit POST /auth/exchange-token
|
||||
- GOO-6: Fix MoMo IPN URL (tách ipnUrl khỏi redirectUrl)
|
||||
- GOO-7: JWT validate user status (isActive + deletedAt)
|
||||
### Fixed
|
||||
- MCP transport controller now requires JWT authentication (BUG-004 resolved)
|
||||
- 21 lint errors from GDPR/logger/caching commits
|
||||
- Replaced `new Logger()` with DI LoggerService across modules
|
||||
- CI workflow branch targets corrected from main to master
|
||||
- Lint error and typecheck failures for MVP launch readiness
|
||||
|
||||
#### Phát hiện chính (P0 — Launch Blockers)
|
||||
- Thiếu Phone-OTP login (phương thức auth chính ở VN)
|
||||
- legalStatus là free-text, không phải enum (tín hiệu tin cậy #1)
|
||||
- Typesense không hỗ trợ tìm kiếm dấu tiếng Việt
|
||||
- Thiếu phòng trọ (ROOM_RENTAL) trong PropertyType enum
|
||||
- Quận 2/9 đã bị xóa (→ Thủ Đức) nhưng vẫn hardcoded trong UI
|
||||
|
||||
### Đã thêm (CEO Audit Wave 13 — 2026-04-12)
|
||||
- Quy trình kiểm tra CEO (TEC-1915) — kiểm tra toàn bộ codebase + xem xét trạng thái dự án
|
||||
- Tài liệu kế hoạch với báo cáo 7 phần: tóm tắt kiểm tra, các vấn đề quan trọng, ưu tiên, khuyến nghị
|
||||
- 6 subtask mới được tạo (TEC-1918 đến TEC-1923) cho Wave 13
|
||||
- Cập nhật PROJECT_TRACKER với phần theo dõi Wave 13
|
||||
|
||||
### Kết Quả QA (2026-04-12)
|
||||
- Lint: PASS (0 lỗi)
|
||||
- TypeScript: 7 lỗi trong các tệp test web (thiếu kiểu vitest) — TEC-1918
|
||||
- Kiểm thử đơn vị: 232 tệp, 1454 bài kiểm thử, TẤT CẢ ĐỀU PASS
|
||||
- Build: TẤT CẢ 3 gói build thành công
|
||||
- Git: Cây làm việc sạch
|
||||
|
||||
### Đã thêm
|
||||
- Kiểm tra toàn diện CEO & kế hoạch triển khai (TEC-1882) — báo cáo 8 phần bao gồm kiến trúc, chất lượng, bảo mật
|
||||
- 7 subtask mới được tạo (TEC-1888 đến TEC-1894) cho Wave 11D-13
|
||||
- Cập nhật PROJECT_TRACKER với theo dõi subtask Waves 11D-13
|
||||
- Cập nhật QA_TRACKER với báo cáo kiểm thử ngày 2026-04-11 (xác định 27 bài kiểm thử thất bại)
|
||||
- Các báo cáo kiểm tra toàn diện: AUDIT_SUMMARY, COMPREHENSIVE_AUDIT, AUDIT_INDEX
|
||||
|
||||
### Đã xác định (từ CEO Audit 2026-04-11)
|
||||
- 725 lỗi ESLint (712 có thể tự động sửa) — TEC-1888
|
||||
- Lỗi TypeScript trong các bài kiểm thử web (json-ld.spec.tsx) — TEC-1888
|
||||
- 27 bài kiểm thử rate limit guard thất bại — TEC-1889
|
||||
- 3 module API chưa hoàn chỉnh (health, metrics, mcp) — TEC-1890
|
||||
- Các MCP server chỉ là stub (~50 dòng mỗi cái) — TEC-1891
|
||||
- Chỉ có 6 bài kiểm thử đơn vị web (cần 50+) — TEC-1892
|
||||
- Không có mã hóa PII ở cấp độ trường — TEC-1893
|
||||
- Không có MFA cho tài khoản agent/admin — TEC-1894
|
||||
|
||||
### Đã thêm trước đó
|
||||
- Tài liệu kế hoạch kiểm tra CEO với ma trận cải tiến & tính năng đầy đủ (TEC-1682)
|
||||
- Các vấn đề Wave 5: sửa lỗ hổng npm, độ phủ kiểm thử, Saved Searches, Dependabot
|
||||
- Kết nối pool PgBouncer cho PostgreSQL môi trường production
|
||||
- Tối ưu hóa SEO — JSON-LD, sitemap động, meta tags cho danh sách bất động sản
|
||||
- Tài liệu tham khảo mã lỗi API
|
||||
- Tăng cường tiêu đề bảo mật cho cả API và ứng dụng Web
|
||||
- Dockerfile production đa giai đoạn cho NestJS API
|
||||
- Kiểm tra giá trị JWT secret khi khởi động (từ chối giá trị giữ chỗ)
|
||||
- Giới hạn kích thước tệp theo loại và phản hồi 413 cho tải lên media
|
||||
- Rate limiting và auth guard cho MCP transport controller
|
||||
- Xử lý lỗi bất đồng bộ cho các handler module quan trọng
|
||||
- Component QueryErrorBoundary với tọa độ bản đồ thực tế (web)
|
||||
- Endpoint xóa dữ liệu người dùng tuân thủ GDPR
|
||||
- Cache kết quả tìm kiếm danh sách bất động sản với decorator @Cacheable
|
||||
- Bản dịch i18n cho Auth + search và khả năng truy cập filter-bar
|
||||
|
||||
### Đã sửa
|
||||
- MCP transport controller hiện yêu cầu xác thực JWT (BUG-004 đã giải quyết)
|
||||
- 21 lỗi lint từ các commit GDPR/logger/caching
|
||||
- Thay thế `new Logger()` bằng DI LoggerService xuyên suốt các module
|
||||
- Đã sửa nhánh đích của CI workflow từ main sang master
|
||||
- Lỗi lint và typecheck để chuẩn bị ra mắt MVP
|
||||
|
||||
### Đã thay đổi
|
||||
- Tách các tệp lớn trong quá trình refactor logger
|
||||
### Changed
|
||||
- Split large files during logger refactor
|
||||
|
||||
---
|
||||
|
||||
## [1.4.0] - 2026-04-08
|
||||
|
||||
### Đã thêm
|
||||
- Redis caching cho kiểm tra quota người dùng với xóa cache theo tiền tố
|
||||
- Kiểm thử đơn vị tầng domain trên tất cả các module (auth, payments, subscriptions, admin, analytics, listings, notifications, reviews, search, metrics)
|
||||
- Các endpoint health check (`/health`, `/health/db`, `/health/redis`) sử dụng `@nestjs/terminus`
|
||||
- Giao diện Định giá Bất động sản với tích hợp AVM (Automated Valuation Model) trên web frontend
|
||||
### Added
|
||||
- Redis caching for user quota checks with prefix-based cache invalidation
|
||||
- Domain layer unit tests across all modules (auth, payments, subscriptions, admin, analytics, listings, notifications, reviews, search, metrics)
|
||||
- Health check endpoints (`/health`, `/health/db`, `/health/redis`) using `@nestjs/terminus`
|
||||
- Property Valuation UI with AVM (Automated Valuation Model) integration on the web frontend
|
||||
|
||||
### Đã thay đổi
|
||||
- Cải thiện cache service với các mẫu xóa theo tiền tố
|
||||
- Nâng cao các handler truy vấn analytics với tầng caching
|
||||
### Changed
|
||||
- Improved cache service with prefix-based clearing patterns
|
||||
- Enhanced analytics query handlers with caching layer
|
||||
|
||||
### Đã sửa
|
||||
- Giải quyết các lỗi lint trên toàn bộ codebase
|
||||
### Fixed
|
||||
- Lint errors resolved across codebase
|
||||
|
||||
---
|
||||
|
||||
## [1.3.0] - 2026-03-28
|
||||
|
||||
### Đã thêm
|
||||
- Hệ thống gửi thông báo hoàn chỉnh với email (Nodemailer + Handlebars), push (Firebase Cloud Messaging), và các kênh trong ứng dụng
|
||||
- Trực quan hóa heatmap quận huyện bằng Mapbox và dashboard hiệu suất agent trên web frontend
|
||||
- Module đánh giá với đầy đủ các endpoint CRUD, các handler CQRS, và value object đánh giá 1-5 sao
|
||||
- Kiểm thử đơn vị cho các module analytics, metrics, notifications, payments và search
|
||||
- Cải thiện geo-search với truy vấn không gian PostGIS và các event handler listing-approved của Typesense
|
||||
- Endpoint `/health` chuyên dụng với phản hồi timestamp
|
||||
### Added
|
||||
- Complete notification delivery system with email (Nodemailer + Handlebars), push (Firebase Cloud Messaging), and in-app channels
|
||||
- Mapbox district heatmap visualization and agent performance dashboard on web frontend
|
||||
- Reviews module with full CRUD endpoints, CQRS handlers, and 1-5 star rating value objects
|
||||
- Unit tests for analytics, metrics, notifications, payments, and search modules
|
||||
- Enhanced geo-search with PostGIS spatial queries and Typesense listing-approved event handlers
|
||||
- Dedicated `/health` endpoint with timestamp response
|
||||
|
||||
### Đã thay đổi
|
||||
- Refactor nội bộ cache service và các handler analytics để tăng độ tin cậy
|
||||
### Changed
|
||||
- Refactored cache service internals and analytics handlers for better reliability
|
||||
|
||||
### Đã sửa
|
||||
- Thiếu các thuộc tính `AuthState` trong các mock kiểm thử web frontend
|
||||
- Cải thiện quy trình E2E: bước Prisma generate, cache trình duyệt, trace artifacts
|
||||
### Fixed
|
||||
- Missing `AuthState` properties in web frontend test mocks
|
||||
- E2E workflow improvements: Prisma generate step, browser cache, trace artifacts
|
||||
|
||||
---
|
||||
|
||||
## [1.2.0] - 2026-03-20
|
||||
|
||||
### Đã thêm
|
||||
- Tích hợp React Query cho data fetching với UX thử lại khi lỗi
|
||||
- Nút chuyển đổi dark mode cho web frontend
|
||||
- Tầng Redis caching cho các đường dẫn hot của search và analytics
|
||||
- Pipeline NLP tiếng Việt (Underthesea) để phân tích mô tả bất động sản trong AI services
|
||||
- `MetricsService`, `HttpMetricsInterceptor` Prometheus, và các hằng số metric tùy chỉnh
|
||||
- Trang Agent Profile, xác minh KYC, Subscription, và bảng điều khiển Payment trên web frontend
|
||||
- Kiểm thử đơn vị cho các MCP server (tìm kiếm bất động sản, phân tích thị trường, định giá)
|
||||
- Kiểm thử đơn vị cho các hàm kiểm tra và tiện ích web frontend
|
||||
### Added
|
||||
- React Query integration for data fetching with error retry UX
|
||||
- Dark mode toggle for web frontend
|
||||
- Redis caching layer for search and analytics hot paths
|
||||
- Vietnamese NLP pipeline (Underthesea) for property description analysis in AI services
|
||||
- Prometheus `MetricsService`, `HttpMetricsInterceptor`, and custom metric constants
|
||||
- Agent Profile, KYC verification, Subscription, and Payment dashboard pages on web frontend
|
||||
- Unit tests for MCP servers (property search, market analytics, valuation)
|
||||
- Unit tests for web frontend validations and utility functions
|
||||
|
||||
### Đã sửa
|
||||
- Xóa thông tin xác thực MinIO được mã hóa cứng; thêm hỗ trợ presigned URL cho tải lên media
|
||||
- Áp dụng kiểm tra JWT secret cho tất cả môi trường (không chỉ production)
|
||||
- Thêm chỉ mục `Review.userId` còn thiếu để tăng hiệu suất truy vấn FK
|
||||
### Fixed
|
||||
- Removed MinIO hardcoded credentials; added presigned URL support for media uploads
|
||||
- JWT secret enforcement in all environments (not just production)
|
||||
- Added missing `Review.userId` index for FK query performance
|
||||
|
||||
---
|
||||
|
||||
## [1.1.0] - 2026-03-12
|
||||
|
||||
### Đã thêm
|
||||
- Dịch vụ phát hiện danh sách bất động sản trùng lặp để ngăn chặn các bài đăng dư thừa
|
||||
- Giới hạn quota subscription với giới hạn tính năng theo gói và đo lường mức sử dụng
|
||||
- Các chiến lược OAuth backend Google và Zalo cho đăng nhập mạng xã hội
|
||||
- 58 bài kiểm thử đơn vị bao phủ các đường dẫn auth, payment và subscription quan trọng
|
||||
- Skeleton loading, error boundary, và cải thiện khả năng truy cập trên web frontend
|
||||
- Tích hợp theo dõi lỗi Sentry cho cả API và ứng dụng web
|
||||
### Added
|
||||
- Listing duplicate detection service to prevent redundant property submissions
|
||||
- Subscription quota enforcement with per-plan feature limits and usage metering
|
||||
- Google and Zalo OAuth backend strategies for social login
|
||||
- 58 unit tests covering critical auth, payment, and subscription paths
|
||||
- Loading skeletons, error boundaries, and accessibility improvements on web frontend
|
||||
- Sentry error tracking integration for both API and web apps
|
||||
|
||||
### Đã sửa
|
||||
- Tăng cường cấu hình triển khai Docker production cho tất cả các dịch vụ
|
||||
### Fixed
|
||||
- Hardened production Docker deployment configuration for all services
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - 2026-03-01
|
||||
|
||||
### Đã thêm
|
||||
### Added
|
||||
|
||||
#### Xác Thực & Bảo Mật
|
||||
- Đăng ký và đăng nhập người dùng bằng số điện thoại và mật khẩu
|
||||
- JWT access token (hết hạn sau 15 phút) với xoay vòng refresh token (hết hạn sau 7 ngày)
|
||||
- Phát hiện xoay vòng dựa trên token family để ngăn chặn tấn công replay
|
||||
- Hỗ trợ đăng nhập mạng xã hội OAuth (Google, Zalo)
|
||||
- Quy trình xác minh KYC (Know Your Customer) (NONE -> PENDING -> VERIFIED/REJECTED)
|
||||
- Kiểm soát truy cập theo vai trò với decorator `@Roles()` (USER, AGENT, ADMIN)
|
||||
- Rate limiting: mặc định 60 req/phút, 10 req/phút cho auth, 20 req/phút cho payment callback
|
||||
- `ThrottlerBehindProxyGuard` để theo dõi IP nhận biết X-Forwarded-For
|
||||
- Tiêu đề bảo mật Helmet, cấu hình CORS
|
||||
- Kiểm tra đầu vào (class-validator) và làm sạch nội dung (sanitize-html)
|
||||
- Bảo vệ CSRF với mẫu double-submit cookie
|
||||
- Che giấu PII trong structured log (Pino)
|
||||
- Băm mật khẩu Bcrypt
|
||||
#### Authentication & Security
|
||||
- User registration and login with phone number and password
|
||||
- JWT access tokens (15-minute expiry) with refresh token rotation (7-day expiry)
|
||||
- Token family-based rotation detection to prevent replay attacks
|
||||
- OAuth social login support (Google, Zalo)
|
||||
- KYC (Know Your Customer) verification workflow (NONE -> PENDING -> VERIFIED/REJECTED)
|
||||
- Role-based access control with `@Roles()` decorator (USER, AGENT, ADMIN)
|
||||
- Rate limiting: 60 req/min default, 10 req/min auth, 20 req/min payment callbacks
|
||||
- `ThrottlerBehindProxyGuard` for X-Forwarded-For-aware IP tracking
|
||||
- Helmet security headers, CORS configuration
|
||||
- Input validation (class-validator) and content sanitization (sanitize-html)
|
||||
- CSRF protection with double-submit cookie pattern
|
||||
- PII masking in structured logs (Pino)
|
||||
- Bcrypt password hashing
|
||||
|
||||
#### Danh Sách Bất Động Sản
|
||||
- CRUD đầy đủ cho danh sách bất động sản với máy trạng thái (DRAFT -> PENDING_REVIEW -> ACTIVE -> RESERVED -> SOLD/RENTED)
|
||||
- Hỗ trợ tải lên media (S3/MinIO) với kiểm tra tệp
|
||||
- Chấm điểm kiểm duyệt hỗ trợ bởi AI qua Claude API
|
||||
- Hàng đợi kiểm duyệt admin với phê duyệt/từ chối hàng loạt
|
||||
- Tạo danh sách bị giới hạn bởi quota gắn với gói subscription
|
||||
#### Property Listings
|
||||
- Full CRUD for property listings with status state machine (DRAFT -> PENDING_REVIEW -> ACTIVE -> RESERVED -> SOLD/RENTED)
|
||||
- Media upload support (S3/MinIO) with file validation
|
||||
- AI-assisted moderation scoring via Claude API
|
||||
- Admin moderation queue with bulk approve/reject
|
||||
- Quota-gated listing creation tied to subscription plans
|
||||
|
||||
#### Tìm Kiếm & Khám Phá
|
||||
- Tìm kiếm bất động sản toàn văn bản qua Typesense với hỗ trợ tiếng Việt
|
||||
- Tìm kiếm địa lý không gian bằng PostGIS (truy vấn lat/long + bán kính)
|
||||
- Lọc nhiều mặt theo giá, loại bất động sản, số phòng ngủ, quận huyện
|
||||
- Cập nhật chỉ mục tìm kiếm theo sự kiện (listing approved/updated/sold -> re-index)
|
||||
- Xóa cache theo tiền tố cho kết quả tìm kiếm
|
||||
#### Search & Discovery
|
||||
- Full-text property search via Typesense with Vietnamese language support
|
||||
- Geo-spatial search using PostGIS (lat/long + radius queries)
|
||||
- Faceted filtering by price, property type, bedrooms, district
|
||||
- Event-driven search index updates (listing approved/updated/sold -> re-index)
|
||||
- Prefix-based cache invalidation for search results
|
||||
|
||||
#### Thanh Toán
|
||||
- Xử lý thanh toán với tích hợp các nhà cung cấp VNPay, MoMo và ZaloPay
|
||||
- Xử lý webhook callback idempotent với xác minh chữ ký
|
||||
- Hỗ trợ hoàn tiền
|
||||
- Chuyển đổi trạng thái nguyên tử (PENDING -> COMPLETED/FAILED)
|
||||
- Phát sự kiện khi hoàn thành/thất bại thanh toán cho xử lý downstream
|
||||
#### Payments
|
||||
- Payment processing with VNPay, MoMo, and ZaloPay provider integration
|
||||
- Idempotent webhook callback handling with signature verification
|
||||
- Payment refund support
|
||||
- Atomic status transitions (PENDING -> COMPLETED/FAILED)
|
||||
- Event emission on payment completion/failure for downstream processing
|
||||
|
||||
#### Subscription & Thanh Toán Định Kỳ
|
||||
- Các gói subscription với cờ tính năng phân tầng (cột JSON)
|
||||
- Đo lường mức sử dụng và kiểm tra quota (được hỗ trợ bởi Redis)
|
||||
- Nâng cấp và hủy gói
|
||||
- Theo dõi lịch sử thanh toán
|
||||
- Theo dõi mức sử dụng theo sự kiện (`listing.created` -> đo lường mức sử dụng)
|
||||
#### Subscriptions & Billing
|
||||
- Subscription plans with tiered feature flags (JSON columns)
|
||||
- Usage metering and quota enforcement (Redis-backed)
|
||||
- Plan upgrades and cancellations
|
||||
- Billing history tracking
|
||||
- Event-driven usage tracking (`listing.created` -> meter usage)
|
||||
|
||||
#### Bảng Điều Khiển Admin
|
||||
- Dashboard với thống kê toàn hệ thống
|
||||
- Quản lý người dùng (liệt kê, xem, cấm/bỏ cấm)
|
||||
- Hàng đợi phê duyệt KYC với hành động phê duyệt/từ chối
|
||||
- Hàng đợi kiểm duyệt danh sách với kiểm duyệt hàng loạt
|
||||
- Thống kê doanh thu và analytics
|
||||
- Điều chỉnh subscription cho người dùng cá nhân
|
||||
#### Admin Panel
|
||||
- Dashboard with system-wide statistics
|
||||
- User management (list, view, ban/unban)
|
||||
- KYC approval queue with approve/reject actions
|
||||
- Listing moderation queue with bulk moderation
|
||||
- Revenue statistics and analytics
|
||||
- Subscription adjustment for individual users
|
||||
|
||||
#### Analytics & Dữ Liệu Thị Trường
|
||||
- Báo cáo thị trường theo quận huyện với tổng hợp không gian PostGIS
|
||||
- Phân tích xu hướng giá theo loại bất động sản và quận huyện
|
||||
- Dữ liệu heatmap quận huyện (tổng hợp địa lý)
|
||||
- Theo dõi và cập nhật chỉ số thị trường
|
||||
- Phân phối báo cáo dựa trên cache
|
||||
#### Analytics & Market Data
|
||||
- District-level market reports with PostGIS spatial aggregation
|
||||
- Price trend analysis by property type and district
|
||||
- District heatmap data (geo aggregates)
|
||||
- Market index tracking and updates
|
||||
- Cache-based report delivery
|
||||
|
||||
#### Thông Báo
|
||||
- Gửi thông báo đa kênh: EMAIL, SMS, PUSH (FCM), IN_APP
|
||||
- 8 listener theo sự kiện: email chào mừng, phê duyệt KYC, phê duyệt/từ chối danh sách, xác nhận/thất bại thanh toán, hết hạn subscription, vượt quota
|
||||
- Mẫu email Handlebars với bản địa hóa tiếng Việt
|
||||
- Tùy chọn thông báo người dùng (từ chối nhận theo kênh/loại)
|
||||
#### Notifications
|
||||
- Multi-channel notification delivery: EMAIL, SMS, PUSH (FCM), IN_APP
|
||||
- 8 event-driven listeners: welcome email, KYC approval, listing approval/rejection, payment confirmation/failure, subscription expiry, quota exceeded
|
||||
- Handlebars email templates with Vietnamese localization
|
||||
- User notification preferences (opt-out per channel/type)
|
||||
|
||||
#### Đánh Giá
|
||||
- Đánh giá bất động sản và agent với xếp hạng 1-5 sao
|
||||
- CRUD đánh giá với tính đa hình đối tượng (agent hoặc bất động sản)
|
||||
- Tính toán xếp hạng trung bình theo đối tượng
|
||||
#### Reviews
|
||||
- Property and agent reviews with 1-5 star ratings
|
||||
- Review CRUD with target polymorphism (agent or property)
|
||||
- Average rating calculation per target
|
||||
|
||||
#### Máy Chủ MCP (Model Context Protocol)
|
||||
#### MCP (Model Context Protocol) Servers
|
||||
- Property Search Server: `search_properties`, `compare_properties`, `get_property_details`
|
||||
- Market Analytics Server: `get_market_report`, `analyze_trends`, `get_price_indices`
|
||||
- Valuation Server: `estimate_valuation`, `extract_features`, `compare_valuations` (XGBoost qua FastAPI)
|
||||
- HTTP transport controller với `McpRegistryService`
|
||||
- Valuation Server: `estimate_valuation`, `extract_features`, `compare_valuations` (XGBoost via FastAPI)
|
||||
- HTTP transport controller with `McpRegistryService`
|
||||
|
||||
#### Dịch Vụ AI
|
||||
- Microservice FastAPI với mô hình định giá bất động sản XGBoost
|
||||
- Kiểm duyệt nội dung mô tả danh sách được hỗ trợ bởi Claude API
|
||||
- Tiền xử lý NLP tiếng Việt với Underthesea
|
||||
#### AI Services
|
||||
- FastAPI microservice with XGBoost property valuation model
|
||||
- Claude API-powered content moderation for listing descriptions
|
||||
- Vietnamese NLP preprocessing with Underthesea
|
||||
|
||||
#### Hạ Tầng
|
||||
- PostgreSQL 16 với extension PostGIS (22 model, chỉ mục không gian)
|
||||
- Tầng Redis caching cho search, analytics, quota và dữ liệu phiên
|
||||
- Công cụ tìm kiếm Typesense với hỗ trợ tiếng Việt
|
||||
- Endpoint Prometheus metrics với histogram thời gian yêu cầu HTTP và bộ đếm tỷ lệ lỗi
|
||||
- Dashboard Grafana tự động cấu hình từ thư mục `monitoring/`
|
||||
- Ghi log JSON có cấu trúc Pino với correlation ID
|
||||
- Prisma ORM với hệ thống migration và dữ liệu seed (quận huyện/phường Thành phố Hồ Chí Minh, bất động sản mẫu, các gói subscription)
|
||||
#### Infrastructure
|
||||
- PostgreSQL 16 with PostGIS extension (22 models, spatial indexes)
|
||||
- Redis caching layer for search, analytics, quota, and session data
|
||||
- Typesense search engine with Vietnamese language support
|
||||
- Prometheus metrics endpoint with HTTP request duration histograms and error rate counters
|
||||
- Grafana dashboards auto-provisioned from `monitoring/` directory
|
||||
- Pino structured JSON logging with correlation IDs
|
||||
- Prisma ORM with migration system and seed data (Ho Chi Minh City districts/wards, sample properties, subscription plans)
|
||||
|
||||
#### Frontend (Next.js 15)
|
||||
- App Router với Tailwind CSS và quản lý trạng thái Zustand
|
||||
- Trang tìm kiếm bất động sản với tích hợp bản đồ Mapbox GL
|
||||
- Trang chi tiết danh sách với thư viện media
|
||||
- Dashboard agent với quản lý KYC, subscription và thanh toán
|
||||
- Trực quan hóa heatmap quận huyện
|
||||
- Giao diện định giá bất động sản với tích hợp AVM
|
||||
- Nút chuyển đổi dark mode
|
||||
- Skeleton loading và error boundary
|
||||
- Văn bản giao diện tiếng Việt xuyên suốt (loại bất động sản, quận huyện, tiền tệ theo VND)
|
||||
- App Router with Tailwind CSS and Zustand state management
|
||||
- Property search page with Mapbox GL map integration
|
||||
- Listing detail pages with media gallery
|
||||
- Agent dashboard with KYC, subscription, and payment management
|
||||
- District heatmap visualization
|
||||
- Property valuation UI with AVM integration
|
||||
- Dark mode toggle
|
||||
- Loading skeletons and error boundaries
|
||||
- Vietnamese UI text throughout (property types, districts, currency in VND)
|
||||
|
||||
#### Trải Nghiệm Nhà Phát Triển
|
||||
- Monorepo với pnpm workspaces và Turborepo
|
||||
- ESLint với các quy tắc sắp xếp import
|
||||
- Định dạng code Prettier
|
||||
- Git hook Husky
|
||||
- Kiểm thử E2E với Playwright (14 tệp kiểm thử web)
|
||||
- CI pipeline GitHub Actions (lint -> typecheck -> test -> build)
|
||||
#### Developer Experience
|
||||
- Monorepo with pnpm workspaces and Turborepo
|
||||
- ESLint with import ordering rules
|
||||
- Prettier code formatting
|
||||
- Husky git hooks
|
||||
- E2E tests with Playwright (14 web test files)
|
||||
- GitHub Actions CI pipeline (lint -> typecheck -> test -> build)
|
||||
|
||||
### Bảo Mật
|
||||
- Lưu trữ token dựa trên cookie httpOnly với tăng cường CSRF
|
||||
- Khóa idempotency trên các luồng thanh toán với kiểm tra số tiền
|
||||
- Kiểm tra magic byte cho tệp tải lên media
|
||||
- Ghi log kiểm tra admin
|
||||
- Kiểm tra audience/issuer JWT
|
||||
- Kiểm tra biến môi trường production
|
||||
- `.env.example` được làm sạch (không rò rỉ bí mật)
|
||||
- Hook tắt dịch vụ nhẹ nhàng để kết thúc tiến trình sạch
|
||||
### Security
|
||||
- httpOnly cookie-based token storage with CSRF hardening
|
||||
- Idempotency keys on payment flows with amount validation
|
||||
- Magic byte file validation for media uploads
|
||||
- Admin audit logging
|
||||
- JWT audience/issuer validation
|
||||
- Production environment variable validation
|
||||
- Sanitized `.env.example` (no leaked secrets)
|
||||
- Graceful shutdown hooks for clean process termination
|
||||
|
||||
[Unreleased]: https://github.com/goodgo/platform-ai/compare/v1.4.0...HEAD
|
||||
[1.4.0]: https://github.com/goodgo/platform-ai/compare/v1.3.0...v1.4.0
|
||||
|
||||
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.
|
||||
2. **Pull/rebase trước khi push** — luôn chạy `git pull --rebase origin <branch>` trước `git push` để giảm merge conflict.
|
||||
3. **Push ngay sau commit** — không giữ commit local quá 1 ngày làm việc. Commit không push = rủi ro mất việc + conflict tăng.
|
||||
4. **Conventional Commits** — bắt buộc (`feat:`, `fix:`, `chore:`, `refactor:`, `docs:`, `test:`, `style:`, `perf:`). Xem [Quy Ước Commit](#quy-ước-commit) bên dưới.
|
||||
5. **KHÔNG push trực tiếp lên `main` / `master`** — luôn dùng feature branch + Pull Request. Branch chính được bảo vệ bằng GitHub branch protection rules.
|
||||
6. **PR phải pass CI** (`lint` → `typecheck` → `test` → `build`) trước khi merge. PR đỏ CI không được merge dù đã approve.
|
||||
7. **Squash-merge khi merge PR** — giữ history trên `main` sạch, mỗi PR = một commit logic.
|
||||
8. **Xóa feature branch sau khi merge** — tránh branch sprawl. GitHub có auto-delete branch sau merge; bật nó trong repo settings.
|
||||
All application-layer error handling uses **domain exceptions** from `@modules/shared/domain/domain-exception`. Never import exception classes from `@nestjs/common` in handlers — use the project's own domain exceptions instead.
|
||||
|
||||
### Flow nhanh cho mỗi task
|
||||
|
||||
```bash
|
||||
# 1. Tạo/chuyển sang feature branch (KHÔNG commit trực tiếp vào main)
|
||||
git checkout -b feature/goo-xx-short-description
|
||||
|
||||
# 2. Làm việc, khi hoàn thành task:
|
||||
git add <files>
|
||||
git commit -m "feat(scope): mô tả ngắn"
|
||||
|
||||
# 3. Đồng bộ & push
|
||||
git pull --rebase origin main # hoặc develop
|
||||
git push -u origin feature/goo-xx-short-description
|
||||
|
||||
# 4. Mở PR, chờ CI xanh + review, squash-merge, xóa branch
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quy Trình Git & Branching
|
||||
|
||||
### Nhánh Chính
|
||||
|
||||
| Nhánh | Mục đích | Protected |
|
||||
|-------|---------|-----------|
|
||||
| `main` / `master` | Production branch — stable releases | ✅ Yes |
|
||||
| `develop` | Development branch — integration point | ✅ Yes |
|
||||
| `feature/*` | Feature branches — phát triển tính năng mới | ❌ No |
|
||||
| `fix/*` | Bug fix branches | ❌ No |
|
||||
| `docs/*` | Documentation updates | ❌ No |
|
||||
| `refactor/*` | Code refactoring, cleanup | ❌ No |
|
||||
|
||||
### Quy Trình Tạo Feature Branch
|
||||
|
||||
```bash
|
||||
# 1. Cập nhật branch chính
|
||||
git checkout develop
|
||||
git pull origin develop
|
||||
|
||||
# 2. Tạo feature branch
|
||||
git checkout -b feature/awesome-feature
|
||||
|
||||
# Naming convention:
|
||||
# feature/user-authentication
|
||||
# fix/csrf-middleware-double-middleware
|
||||
# docs/api-documentation
|
||||
# refactor/remove-dead-code
|
||||
```
|
||||
|
||||
### Pull Request Workflow
|
||||
|
||||
```bash
|
||||
# 1. Commit changes
|
||||
git add .
|
||||
git commit -m "feat(auth): implement phone OTP login"
|
||||
|
||||
# 2. Push to origin
|
||||
git push origin feature/awesome-feature
|
||||
|
||||
# 3. Open PR on GitHub
|
||||
# - Title: Short summary (max 70 chars)
|
||||
# - Description: Why, what changed, how to test
|
||||
# - Link related issues: Fixes #GOO-7
|
||||
# - Request reviewers: team lead, domain expert
|
||||
|
||||
# 4. Address review feedback
|
||||
git add .
|
||||
git commit -m "refactor(auth): address PR feedback"
|
||||
git push
|
||||
|
||||
# 5. Merge when approved
|
||||
# - Squash commits if many small fixes
|
||||
# - Delete branch after merge
|
||||
```
|
||||
|
||||
### Protected Branch Rules
|
||||
|
||||
`main` 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.
|
||||
The `GlobalExceptionFilter` catches all exceptions and normalizes them into a consistent JSON response with structured error codes.
|
||||
|
||||
### Domain Exceptions
|
||||
|
||||
| Exception | HTTP Status | Khi nào dùng |
|
||||
| Exception | HTTP Status | When to use |
|
||||
|---|---|---|
|
||||
| `NotFoundException(entity, id?)` | 404 | Không tìm thấy entity trong database |
|
||||
| `ValidationException(message, details?)` | 400 | Dữ liệu đầu vào không hợp lệ, vi phạm business rule, lỗi tạo value object |
|
||||
| `ConflictException(message)` | 409 | Tài nguyên bị trùng lặp, vi phạm idempotency |
|
||||
| `UnauthorizedException(message?)` | 401 | Thông tin xác thực hoặc token không hợp lệ/đã hết hạn |
|
||||
| `ForbiddenException(message?)` | 403 | Đã xác thực nhưng không được phép thực hiện hành động |
|
||||
| `NotFoundException(entity, id?)` | 404 | Entity not found in database |
|
||||
| `ValidationException(message, details?)` | 400 | Invalid input, business rule violation, value object creation failure |
|
||||
| `ConflictException(message)` | 409 | Duplicate resource, idempotency violation |
|
||||
| `UnauthorizedException(message?)` | 401 | Invalid/expired credentials or tokens |
|
||||
| `ForbiddenException(message?)` | 403 | Authenticated but not authorized for the action |
|
||||
|
||||
Import từ:
|
||||
Import from:
|
||||
|
||||
```typescript
|
||||
import { NotFoundException, ValidationException } from '@modules/shared/domain/domain-exception';
|
||||
```
|
||||
|
||||
### Các Mẫu Theo Từng Tầng
|
||||
### Patterns by Layer
|
||||
|
||||
#### Command/Query Handlers
|
||||
|
||||
Handlers ném domain exceptions trực tiếp. Không cần bọc try-catch — `GlobalExceptionFilter` xử lý các exception chưa được bắt.
|
||||
Handlers throw domain exceptions directly. No try-catch wrapping needed — the `GlobalExceptionFilter` handles uncaught exceptions.
|
||||
|
||||
```typescript
|
||||
// Good: domain exception with entity context
|
||||
@@ -286,11 +50,11 @@ if (subscription.status === 'CANCELLED') {
|
||||
|
||||
#### Controllers
|
||||
|
||||
Controllers là các tầng ủy quyền mỏng — chúng dispatch tới command/query bus và trả về kết quả. Không cần xử lý lỗi ở tầng controller.
|
||||
Controllers are thin delegation layers — they dispatch to the command/query bus and return the result. No error handling needed at the controller level.
|
||||
|
||||
#### Domain Services / Value Objects
|
||||
|
||||
Sử dụng mẫu `Result<T, E>` từ `@modules/shared/domain/result`:
|
||||
Use the `Result<T, E>` pattern from `@modules/shared/domain/result`:
|
||||
|
||||
```typescript
|
||||
static create(value: string): Result<Phone, string> {
|
||||
@@ -299,9 +63,9 @@ static create(value: string): Result<Phone, string> {
|
||||
}
|
||||
```
|
||||
|
||||
Handlers sử dụng `Result` bằng cách kiểm tra `.isErr` và ném `ValidationException`.
|
||||
Handlers consume `Result` by checking `.isErr` and throwing a `ValidationException`.
|
||||
|
||||
### Những Điều KHÔNG Nên Làm
|
||||
### What NOT to Do
|
||||
|
||||
```typescript
|
||||
// Bad: NestJS built-in exceptions (missing errorCode in response)
|
||||
@@ -321,89 +85,8 @@ try {
|
||||
// Handlers should unwrap Result and throw on error
|
||||
```
|
||||
|
||||
### Kiểu Trả Về Của Repository
|
||||
### Repository Return Types
|
||||
|
||||
Tất cả các phương thức đọc của repository phải trả về DTOs được định kiểu rõ ràng — không bao giờ dùng `Promise<any>` hoặc `PaginatedResult<any>`. Định nghĩa read DTOs ở tầng domain cùng với interface của repository.
|
||||
|
||||
Xem `listing-read.dto.ts` để tham khảo ví dụ chuẩn.
|
||||
|
||||
---
|
||||
|
||||
## Code Review Checklist
|
||||
|
||||
Khi review PR, kiểm tra:
|
||||
|
||||
### Functionality
|
||||
- [ ] Changes meet acceptance criteria
|
||||
- [ ] No breaking changes (or documented)
|
||||
- [ ] Error handling is robust
|
||||
- [ ] Edge cases covered
|
||||
|
||||
### Code Quality
|
||||
- [ ] Code follows conventions (style, naming, patterns)
|
||||
- [ ] No `console.log`, `TODO` without issue reference
|
||||
- [ ] No dead code, unused imports
|
||||
- [ ] Functions have clear responsibility
|
||||
|
||||
### Testing
|
||||
- [ ] Unit tests cover happy path + error cases
|
||||
- [ ] E2E tests for critical flows (if applicable)
|
||||
- [ ] Coverage maintained / improved (API ≥60%, Web ≥50%)
|
||||
- [ ] No flaky tests
|
||||
|
||||
### Documentation
|
||||
- [ ] Code comments explain "why", not "what"
|
||||
- [ ] Updated docs if API/process changed
|
||||
- [ ] Commit messages follow conventions
|
||||
|
||||
### Security
|
||||
- [ ] No hardcoded secrets (API keys, passwords)
|
||||
- [ ] Input validation in place
|
||||
- [ ] Auth checks in place
|
||||
- [ ] No SQL injection (use Prisma, not raw SQL)
|
||||
|
||||
### Performance
|
||||
- [ ] No N+1 queries
|
||||
- [ ] Caching applied where appropriate
|
||||
- [ ] No blocking operations in event loop
|
||||
|
||||
---
|
||||
|
||||
## Release Process
|
||||
|
||||
### Versioning
|
||||
|
||||
Tuân theo **Semantic Versioning**: `MAJOR.MINOR.PATCH`
|
||||
|
||||
- **MAJOR:** Breaking changes (require migration)
|
||||
- **MINOR:** New features (backward compatible)
|
||||
- **PATCH:** Bug fixes
|
||||
|
||||
### Creating a Release
|
||||
|
||||
```bash
|
||||
# 1. Update CHANGELOG.md with changes
|
||||
# 2. Bump version in package.json (root)
|
||||
# 3. Create git tag
|
||||
git tag -a v1.5.0 -m "Release 1.5.0: Add phone OTP login"
|
||||
git push origin v1.5.0
|
||||
|
||||
# 4. GitHub Actions automatically:
|
||||
# - Builds Docker image
|
||||
# - Pushes to GitHub Container Registry
|
||||
# - Creates GitHub Release
|
||||
# - Deploys to staging (auto)
|
||||
# - Waits for manual approval for production
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Questions?
|
||||
|
||||
- 📖 Read `/docs/architecture.md` for system design
|
||||
- 🏗️ Read `/docs/QUICK_REFERENCE.md` for patterns
|
||||
- 💬 Ask on Slack `#dev` channel
|
||||
- 🐛 File an issue: https://github.com/hongochai10/goodgo-bds-platform-ai/issues
|
||||
|
||||
**Happy coding! 🚀**
|
||||
All repository read methods must return explicitly typed DTOs — never `Promise<any>` or `PaginatedResult<any>`. Define read DTOs in the domain layer alongside the repository interface.
|
||||
|
||||
See `listing-read.dto.ts` for the canonical example.
|
||||
|
||||
106
README.md
106
README.md
@@ -1,23 +1,23 @@
|
||||
# GoodGo Platform AI
|
||||
|
||||
Nền tảng bất động sản thông minh của Việt Nam — tìm kiếm nhà đất, định giá bằng AI và quản lý giao dịch toàn trình.
|
||||
Vietnam's intelligent real estate platform — property search, AI-powered valuation, and end-to-end transaction management.
|
||||
|
||||
## Công Nghệ Sử Dụng
|
||||
## Tech Stack
|
||||
|
||||
| Tầng | Công nghệ |
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| **Backend** | NestJS 11, TypeScript, Prisma ORM, CQRS |
|
||||
| **Frontend** | Next.js 15, React 18, Tailwind CSS, Zustand |
|
||||
| **Cơ sở dữ liệu** | PostgreSQL 16 + PostGIS 3.4 |
|
||||
| **Tìm kiếm** | Typesense 27 |
|
||||
| **Database** | PostgreSQL 16 + PostGIS 3.4 |
|
||||
| **Search** | Typesense 27 |
|
||||
| **Cache/Queue** | Redis 7 |
|
||||
| **AI/ML** | FastAPI, XGBoost, Claude API, Underthesea |
|
||||
| **MCP** | Model Context Protocol servers (tìm kiếm nhà đất, định giá, phân tích) |
|
||||
| **Lưu trữ** | MinIO (tương thích S3) |
|
||||
| **Giám sát** | Prometheus, Grafana, Loki + Promtail |
|
||||
| **Thanh toán** | VNPay, MoMo, ZaloPay |
|
||||
| **MCP** | Model Context Protocol servers (property search, valuation, analytics) |
|
||||
| **Storage** | MinIO (S3-compatible) |
|
||||
| **Monitoring** | Prometheus, Grafana, Loki + Promtail |
|
||||
| **Payments** | VNPay, MoMo, ZaloPay |
|
||||
|
||||
## Tổng Quan Kiến Trúc
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────┐ ┌──────────────────┐
|
||||
@@ -47,7 +47,7 @@ Nền tảng bất động sản thông minh của Việt Nam — tìm kiếm nh
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
## Cấu Trúc Monorepo
|
||||
## Monorepo Structure
|
||||
|
||||
```
|
||||
goodgo-platform-ai/
|
||||
@@ -64,15 +64,15 @@ goodgo-platform-ai/
|
||||
└── docs/ # Developer documentation
|
||||
```
|
||||
|
||||
## Khởi Động Nhanh
|
||||
## Quick Start
|
||||
|
||||
### Yêu Cầu Tiên Quyết
|
||||
### Prerequisites
|
||||
|
||||
- **Docker Engine 24+** & Docker Compose v2
|
||||
- **Node.js 22 LTS**
|
||||
- **pnpm 10.27+** (`corepack enable && corepack prepare pnpm@latest --activate`)
|
||||
|
||||
### Cài Đặt
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
@@ -103,26 +103,26 @@ pnpm db:seed
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
API sẽ khả dụng tại `http://localhost:3001/api/v1` và ứng dụng web tại `http://localhost:3000`.
|
||||
The API will be available at `http://localhost:3001/api/v1` and the web app at `http://localhost:3000`.
|
||||
|
||||
> **Swagger UI**: Mở `http://localhost:3001/api/v1/docs` để xem tài liệu API tương tác.
|
||||
> **Swagger UI**: Open `http://localhost:3001/api/v1/docs` for interactive API documentation.
|
||||
|
||||
### Các Dịch Vụ Hạ Tầng
|
||||
### Infrastructure Services
|
||||
|
||||
| Dịch vụ | Cổng | Bảng điều khiển |
|
||||
| Service | Port(s) | Dashboard |
|
||||
|---------|---------|-----------|
|
||||
| PostgreSQL + PostGIS | 5432 | — |
|
||||
| Redis | 6379 | — |
|
||||
| Typesense | 8108 | `http://localhost:8108/health` |
|
||||
| MinIO | 9000 / 9001 | `http://localhost:9001` (console) |
|
||||
| AI Services (FastAPI) | 8000 | `http://localhost:8000/health` |
|
||||
| Loki (tổng hợp log) | 3100 | `http://localhost:3100/ready` |
|
||||
| Loki (log aggregation) | 3100 | `http://localhost:3100/ready` |
|
||||
| Prometheus | 9090 | `http://localhost:9090` |
|
||||
| Grafana | 3002 | `http://localhost:3002` |
|
||||
|
||||
## Phát Triển
|
||||
## Development
|
||||
|
||||
### Các Lệnh Thông Dụng
|
||||
### Common Commands
|
||||
|
||||
```bash
|
||||
pnpm dev # Start all apps (API + Web)
|
||||
@@ -133,7 +133,7 @@ pnpm format # Format with Prettier
|
||||
pnpm test # Run unit/integration tests
|
||||
```
|
||||
|
||||
### Cơ Sở Dữ Liệu
|
||||
### Database
|
||||
|
||||
```bash
|
||||
pnpm db:generate # Regenerate Prisma client
|
||||
@@ -144,7 +144,7 @@ pnpm db:studio # Open Prisma Studio (visual editor)
|
||||
pnpm db:reset # Reset database (destructive)
|
||||
```
|
||||
|
||||
### Kiểm Thử E2E
|
||||
### E2E Testing
|
||||
|
||||
```bash
|
||||
pnpm test:e2e # Run all E2E tests
|
||||
@@ -153,41 +153,41 @@ pnpm test:e2e:web # Web UI tests only
|
||||
pnpm test:e2e:report # Open HTML test report
|
||||
```
|
||||
|
||||
## Các Module API
|
||||
## API Modules
|
||||
|
||||
Tất cả route API đều có tiền tố `/api/v1/`. Mỗi module tuân theo Domain-Driven Design với các tầng `presentation/`, `application/`, `domain/` 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 |
|
||||
| **listings** | CRUD tin đăng nhà đất, quy trình trạng thái, quản lý tệp phương tiện |
|
||||
| **search** | Tìm kiếm toàn văn bản Typesense kết hợp bộ lọc địa lý, lưu tìm kiếm |
|
||||
| **payments** | Tích hợp VNPay, MoMo, ZaloPay kèm xác thực callback |
|
||||
| **subscriptions** | Quản lý gói dịch vụ, theo dõi mức sử dụng, kiểm soát hạn mức |
|
||||
| **notifications** | Lịch sử thông báo qua email và trong ứng dụng cùng tuỳ chọn cá nhân |
|
||||
| **admin** | Kiểm duyệt tin đăng, quản lý người dùng, nhật ký kiểm tra |
|
||||
| **analytics** | Báo cáo thị trường, chỉ số giá, tích hợp AVM |
|
||||
| **agents** | Hồ sơ và xác minh môi giới bất động sản |
|
||||
| **inquiries** | Quản lý yêu cầu tư vấn nhà đất |
|
||||
| **leads** | Theo dõi và chuyển đổi khách hàng tiềm năng |
|
||||
| **reviews** | Đánh giá và xếp hạng bất động sản |
|
||||
| **health** | Kiểm tra liveness và readiness |
|
||||
| **mcp** | Cầu nối MCP server (tìm kiếm nhà đất, định giá, phân tích) |
|
||||
| **metrics** | Thu thập metrics Prometheus và web vitals |
|
||||
| **shared** | Mối quan tâm chung: guards, pipes, filters, dịch vụ Prisma/Redis |
|
||||
| **auth** | Registration, login, JWT + refresh token rotation, OAuth (Google/Zalo), KYC, user data export/deletion |
|
||||
| **listings** | Property listing CRUD, status workflow, media management |
|
||||
| **search** | Typesense full-text search with geo-spatial filters, saved searches |
|
||||
| **payments** | VNPay, MoMo, ZaloPay integration with callback verification |
|
||||
| **subscriptions** | Plan management, usage tracking, quota enforcement |
|
||||
| **notifications** | Email and in-app notification history & preferences |
|
||||
| **admin** | Listing moderation, user management, audit logs |
|
||||
| **analytics** | Market reports, price indices, AVM integration |
|
||||
| **agents** | Real estate agent profiles and verification |
|
||||
| **inquiries** | Property inquiry management |
|
||||
| **leads** | Lead tracking and conversion |
|
||||
| **reviews** | Property reviews and ratings |
|
||||
| **health** | Liveness and readiness health checks |
|
||||
| **mcp** | MCP server bridge (property search, valuation, analytics) |
|
||||
| **metrics** | Prometheus metrics and web vitals collection |
|
||||
| **shared** | Cross-cutting concerns: guards, pipes, filters, Prisma/Redis services |
|
||||
|
||||
## Tài Liệu
|
||||
## Documentation
|
||||
|
||||
| Tài liệu | Mô tả |
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [Môi trường phát triển](docs/dev-environment.md) | Cài đặt Docker và các dịch vụ cục bộ |
|
||||
| [Kiến trúc](docs/architecture.md) | Thiết kế hệ thống, luồng dữ liệu, cấu trúc module |
|
||||
| [API Endpoints](docs/api-endpoints.md) | Tài liệu tham khảo REST API endpoint |
|
||||
| [Mã lỗi API](docs/api-error-codes.md) | Định dạng phản hồi lỗi và toàn bộ mã lỗi |
|
||||
| [Triển khai](docs/deployment.md) | Hướng dẫn triển khai môi trường sản xuất |
|
||||
| [Sao lưu & Khôi phục](docs/backup-restore.md) | Quy trình sao lưu và khôi phục sau sự cố |
|
||||
| [Đóng góp](CONTRIBUTING.md) | Quy ước xử lý lỗi và các mẫu lập trình |
|
||||
| [Development Environment](docs/dev-environment.md) | Docker setup and local services |
|
||||
| [Architecture](docs/architecture.md) | System design, data flow, module structure |
|
||||
| [API Endpoints](docs/api-endpoints.md) | REST API endpoint reference |
|
||||
| [API Error Codes](docs/api-error-codes.md) | Error response format and all error codes |
|
||||
| [Deployment](docs/deployment.md) | Production deployment guide |
|
||||
| [Backup & Restore](docs/backup-restore.md) | Backup procedures and disaster recovery |
|
||||
| [Contributing](CONTRIBUTING.md) | Error handling conventions and coding patterns |
|
||||
|
||||
## Giấy Phép
|
||||
## License
|
||||
|
||||
Độc quyền — Bảo lưu mọi quyền.
|
||||
Proprietary — All rights reserved.
|
||||
|
||||
@@ -11,7 +11,7 @@ set -e
|
||||
|
||||
if [ "${RUN_MIGRATIONS}" = "true" ]; then
|
||||
echo "[entrypoint] Running Prisma migrations..."
|
||||
npx prisma migrate deploy --schema /app/prisma/schema.prisma
|
||||
npx prisma migrate deploy --schema ./prisma/schema.prisma
|
||||
echo "[entrypoint] Migrations complete."
|
||||
fi
|
||||
|
||||
|
||||
@@ -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",
|
||||
"@aws-sdk/client-s3": "^3.1026.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1026.0",
|
||||
"@bull-board/api": "^7.0.0",
|
||||
"@bull-board/express": "^7.0.0",
|
||||
"@bull-board/nestjs": "^7.0.0",
|
||||
"@goodgo/mcp-servers": "workspace:*",
|
||||
"@goodgo/contracts-events": "workspace:*",
|
||||
"@nest-lab/throttler-storage-redis": "^1.2.0",
|
||||
"@nestjs/bullmq": "^11.0.4",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
@@ -42,7 +37,6 @@
|
||||
"@prisma/client": "^7.7.0",
|
||||
"@sentry/nestjs": "^10.47.0",
|
||||
"@sentry/profiling-node": "^10.47.0",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"@willsoto/nestjs-prometheus": "^6.1.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bullmq": "^5.74.1",
|
||||
@@ -53,7 +47,6 @@
|
||||
"handlebars": "^4.7.9",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.4.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"nodemailer": "^8.0.5",
|
||||
"otplib": "^13.4.0",
|
||||
"passport": "^0.7.0",
|
||||
@@ -80,7 +73,6 @@
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/passport-google-oauth20": "^2.0.17",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { type MiddlewareConsumer, Module, type NestModule, RequestMethod } from '@nestjs/common';
|
||||
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
@@ -10,7 +9,6 @@ import { AdminModule } from '@modules/admin';
|
||||
import { AgentsModule } from '@modules/agents';
|
||||
import { AnalyticsModule } from '@modules/analytics';
|
||||
import { AuthModule } from '@modules/auth';
|
||||
import { FavoritesModule } from '@modules/favorites';
|
||||
import { HealthModule } from '@modules/health';
|
||||
import { IndustrialModule } from '@modules/industrial';
|
||||
import { InquiriesModule } from '@modules/inquiries';
|
||||
@@ -20,11 +18,7 @@ import { McpIntegrationModule } from '@modules/mcp';
|
||||
import { MessagingModule } from '@modules/messaging';
|
||||
import { HttpMetricsInterceptor, MetricsModule } from '@modules/metrics';
|
||||
import { NotificationsModule } from '@modules/notifications';
|
||||
import { OsmSyncModule } from '@modules/osm-sync/osm-sync.module';
|
||||
import { PaymentsModule } from '@modules/payments';
|
||||
import { PoiModule } from '@modules/poi/poi.module';
|
||||
import { ProjectsModule } from '@modules/projects';
|
||||
import { QueuesModule } from '@modules/queues/queues.module';
|
||||
import { ReportsModule } from '@modules/reports';
|
||||
import { ReviewsModule } from '@modules/reviews';
|
||||
import { SearchModule } from '@modules/search';
|
||||
@@ -32,7 +26,6 @@ import { SharedModule } from '@modules/shared';
|
||||
import { ThrottlerBehindProxyGuard } from '@modules/shared/infrastructure/guards/throttler-behind-proxy.guard';
|
||||
import { CsrfMiddleware } from '@modules/shared/infrastructure/middleware/csrf.middleware';
|
||||
import { SanitizeInputMiddleware } from '@modules/shared/infrastructure/middleware/sanitize-input.middleware';
|
||||
import { getRedisConnection } from '@modules/shared/infrastructure/redis-connection.config';
|
||||
import { SubscriptionsModule } from '@modules/subscriptions';
|
||||
import { TransferModule } from '@modules/transfer';
|
||||
import { AppController } from './app.controller';
|
||||
@@ -41,11 +34,11 @@ import { AppController } from './app.controller';
|
||||
imports: [
|
||||
SentryModule.forRoot(),
|
||||
BullModule.forRoot({
|
||||
// RFC-004 Phase 3 — use the queue-specific Redis connection so ops can
|
||||
// split cache traffic from queue traffic without a code change. Falls
|
||||
// back to REDIS_HOST/PORT/PASSWORD when the queue-specific vars are
|
||||
// unset. See shared/infrastructure/redis-connection.config.ts.
|
||||
connection: getRedisConnection('queue'),
|
||||
connection: {
|
||||
host: process.env['REDIS_HOST'] ?? 'localhost',
|
||||
port: Number(process.env['REDIS_PORT'] ?? 6379),
|
||||
password: process.env['REDIS_PASSWORD'] ?? undefined,
|
||||
},
|
||||
}),
|
||||
CqrsModule.forRoot(),
|
||||
ScheduleModule.forRoot(),
|
||||
@@ -57,32 +50,22 @@ import { AppController } from './app.controller';
|
||||
LeadsModule,
|
||||
ListingsModule,
|
||||
ReviewsModule,
|
||||
FavoritesModule,
|
||||
SearchModule,
|
||||
NotificationsModule,
|
||||
OsmSyncModule,
|
||||
PaymentsModule,
|
||||
PoiModule,
|
||||
SubscriptionsModule,
|
||||
AdminModule,
|
||||
AnalyticsModule,
|
||||
MetricsModule,
|
||||
MetricsModule.withQueueMetrics(),
|
||||
McpIntegrationModule,
|
||||
MessagingModule,
|
||||
ReportsModule,
|
||||
ProjectsModule,
|
||||
IndustrialModule,
|
||||
TransferModule,
|
||||
|
||||
// ── Bull Board UI (RFC-004 Phase 3 WS3b) ──
|
||||
QueuesModule,
|
||||
|
||||
// ── Rate Limiting ──
|
||||
// Default: 60 requests per 60 seconds per IP
|
||||
// Override per-route with @Throttle() decorator
|
||||
// Storage: Redis-backed sliding window so limits are shared across
|
||||
// every API instance (required for TEC-2930 feature-listing throttling).
|
||||
ThrottlerModule.forRoot({
|
||||
throttlers: [
|
||||
{
|
||||
@@ -101,21 +84,6 @@ import { AppController } from './app.controller';
|
||||
limit: process.env['NODE_ENV'] === 'test' || process.env['NODE_ENV'] === 'development' ? 10_000 : 20,
|
||||
},
|
||||
],
|
||||
storage: new ThrottlerStorageRedisService({
|
||||
host: process.env['REDIS_HOST'] ?? 'localhost',
|
||||
port: Number(process.env['REDIS_PORT'] ?? 6379),
|
||||
password: process.env['REDIS_PASSWORD'] ?? undefined,
|
||||
// Single retry per command + bounded reconnect backoff so a
|
||||
// transient Redis blip cannot stall the request path. Behaviour
|
||||
// matches RedisService for consistency.
|
||||
maxRetriesPerRequest: 1,
|
||||
enableReadyCheck: false,
|
||||
lazyConnect: true,
|
||||
retryStrategy(times: number): number {
|
||||
return Math.min(times * 1000, 5000);
|
||||
},
|
||||
keyPrefix: 'throttler:',
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AppController],
|
||||
@@ -150,10 +118,6 @@ export class AppModule implements NestModule {
|
||||
.exclude(
|
||||
{ path: 'health', method: RequestMethod.GET },
|
||||
{ path: 'health/(.*)', method: RequestMethod.GET },
|
||||
{ path: 'api/v1/web-vitals', method: RequestMethod.POST }, // sendBeacon cannot send CSRF headers
|
||||
{ path: 'web-vitals', method: RequestMethod.POST }, // middleware exclude uses controller-relative path
|
||||
{ path: 'api/v1/admin/queues', method: RequestMethod.ALL },
|
||||
{ path: 'api/v1/admin/queues/(.*)', method: RequestMethod.ALL },
|
||||
)
|
||||
.forRoutes('*');
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ const isTest = process.env['NODE_ENV'] === 'test';
|
||||
const integrations: any[] = [];
|
||||
if (!isTest) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { nodeProfilingIntegration } = require('@sentry/profiling-node') as typeof import('@sentry/profiling-node');
|
||||
integrations.push(nodeProfilingIntegration());
|
||||
} catch {
|
||||
|
||||
@@ -8,10 +8,11 @@ import './instrument';
|
||||
|
||||
import { RequestMethod, ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { IoAdapter } from '@nestjs/platform-socket.io';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import helmet from 'helmet';
|
||||
import { LoggerService, RedisIoAdapter, validateEnv } from '@modules/shared';
|
||||
import { LoggerService, validateEnv } from '@modules/shared';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
@@ -59,11 +60,7 @@ async function bootstrap() {
|
||||
});
|
||||
|
||||
// ── WebSocket Adapter (Socket.IO) ──
|
||||
// Redis pub/sub fan-out for multi-instance broadcasts; falls back to the
|
||||
// in-memory IoAdapter when Redis is unreachable (single-node / local dev).
|
||||
const wsAdapter = new RedisIoAdapter(app);
|
||||
await wsAdapter.connectToRedis();
|
||||
app.useWebSocketAdapter(wsAdapter);
|
||||
app.useWebSocketAdapter(new IoAdapter(app));
|
||||
|
||||
// ── Security Headers (Helmet) ──
|
||||
app.use(
|
||||
|
||||
@@ -1,43 +1,30 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { AuthModule } from '@modules/auth';
|
||||
import { ListingsModule } from '@modules/listings';
|
||||
import { AI_CONFIG_PROVIDER } from '@modules/shared';
|
||||
import { SubscriptionsModule } from '@modules/subscriptions';
|
||||
import { AdjustSubscriptionHandler } from './application/commands/adjust-subscription/adjust-subscription.handler';
|
||||
import { ApproveKycHandler } from './application/commands/approve-kyc/approve-kyc.handler';
|
||||
import { ApproveListingHandler } from './application/commands/approve-listing/approve-listing.handler';
|
||||
import { BanUserHandler } from './application/commands/ban-user/ban-user.handler';
|
||||
import { BulkModerateListingsHandler } from './application/commands/bulk-moderate-listings/bulk-moderate-listings.handler';
|
||||
import { ProvisionDeveloperHandler } from './application/commands/provision-developer/provision-developer.handler';
|
||||
import { ProvisionParkOperatorHandler } from './application/commands/provision-park-operator/provision-park-operator.handler';
|
||||
import { RejectKycHandler } from './application/commands/reject-kyc/reject-kyc.handler';
|
||||
import { RejectListingHandler } from './application/commands/reject-listing/reject-listing.handler';
|
||||
import { UpdateAiSettingsHandler } from './application/commands/update-ai-settings/update-ai-settings.handler';
|
||||
import { UpdateUserStatusHandler } from './application/commands/update-user-status/update-user-status.handler';
|
||||
import { AdminAuditListener } from './application/listeners/admin-audit.listener';
|
||||
import { ModerationAuditListener } from './application/listeners/moderation-audit.listener';
|
||||
import { UserBannedListener } from './application/listeners/user-banned.listener';
|
||||
import { UserDeactivatedListener } from './application/listeners/user-deactivated.listener';
|
||||
import { GetAiSettingsHandler } from './application/queries/get-ai-settings/get-ai-settings.handler';
|
||||
import { GetAuditLogsHandler } from './application/queries/get-audit-logs/get-audit-logs.handler';
|
||||
import { GetDashboardStatsHandler } from './application/queries/get-dashboard-stats/get-dashboard-stats.handler';
|
||||
import { GetFlaggedListingsHandler } from './application/queries/get-flagged-listings/get-flagged-listings.handler';
|
||||
import { GetKycQueueHandler } from './application/queries/get-kyc-queue/get-kyc-queue.handler';
|
||||
import { GetModerationAuditLogsHandler } from './application/queries/get-moderation-audit-logs/get-moderation-audit-logs.handler';
|
||||
import { GetModerationQueueHandler } from './application/queries/get-moderation-queue/get-moderation-queue.handler';
|
||||
import { GetRevenueStatsHandler } from './application/queries/get-revenue-stats/get-revenue-stats.handler';
|
||||
import { GetUserDetailHandler } from './application/queries/get-user-detail/get-user-detail.handler';
|
||||
import { GetUsersHandler } from './application/queries/get-users/get-users.handler';
|
||||
import { SystemSettingsService } from './application/services/system-settings.service';
|
||||
import { ADMIN_QUERY_REPOSITORY } from './domain/repositories/admin-query.repository';
|
||||
import { AUDIT_LOG_REPOSITORY } from './domain/repositories/audit-log.repository';
|
||||
import { MODERATION_AUDIT_LOG_REPOSITORY } from './domain/repositories/moderation-audit-log.repository';
|
||||
import { SystemSettingsAiConfigProvider } from './infrastructure/adapters/system-settings-ai-config.provider';
|
||||
import { PrismaAdminQueryRepository } from './infrastructure/repositories/prisma-admin-query.repository';
|
||||
import { PrismaAuditLogRepository } from './infrastructure/repositories/prisma-audit-log.repository';
|
||||
import { PrismaModerationAuditLogRepository } from './infrastructure/repositories/prisma-moderation-audit-log.repository';
|
||||
import { AdminModerationAuditController } from './presentation/controllers/admin-moderation-audit.controller';
|
||||
import { AdminModerationController } from './presentation/controllers/admin-moderation.controller';
|
||||
import { AdminController } from './presentation/controllers/admin.controller';
|
||||
|
||||
@@ -50,43 +37,25 @@ const CommandHandlers = [
|
||||
ApproveKycHandler,
|
||||
RejectKycHandler,
|
||||
BulkModerateListingsHandler,
|
||||
UpdateAiSettingsHandler,
|
||||
ProvisionDeveloperHandler,
|
||||
ProvisionParkOperatorHandler,
|
||||
];
|
||||
|
||||
const QueryHandlers = [
|
||||
GetModerationQueueHandler,
|
||||
GetFlaggedListingsHandler,
|
||||
GetDashboardStatsHandler,
|
||||
GetRevenueStatsHandler,
|
||||
GetUsersHandler,
|
||||
GetUserDetailHandler,
|
||||
GetKycQueueHandler,
|
||||
GetAuditLogsHandler,
|
||||
GetModerationAuditLogsHandler,
|
||||
GetAiSettingsHandler,
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [CqrsModule, AuthModule, forwardRef(() => ListingsModule), SubscriptionsModule],
|
||||
controllers: [
|
||||
AdminController,
|
||||
AdminModerationController,
|
||||
AdminModerationAuditController,
|
||||
],
|
||||
imports: [CqrsModule, AuthModule, ListingsModule, SubscriptionsModule],
|
||||
controllers: [AdminController, AdminModerationController],
|
||||
providers: [
|
||||
// Repositories
|
||||
{ provide: ADMIN_QUERY_REPOSITORY, useClass: PrismaAdminQueryRepository },
|
||||
{ provide: AUDIT_LOG_REPOSITORY, useClass: PrismaAuditLogRepository },
|
||||
{
|
||||
provide: MODERATION_AUDIT_LOG_REPOSITORY,
|
||||
useClass: PrismaModerationAuditLogRepository,
|
||||
},
|
||||
|
||||
// Services
|
||||
SystemSettingsService,
|
||||
{ provide: AI_CONFIG_PROVIDER, useClass: SystemSettingsAiConfigProvider },
|
||||
|
||||
// CQRS
|
||||
...CommandHandlers,
|
||||
@@ -96,8 +65,6 @@ const QueryHandlers = [
|
||||
UserBannedListener,
|
||||
UserDeactivatedListener,
|
||||
AdminAuditListener,
|
||||
ModerationAuditListener,
|
||||
],
|
||||
exports: [SystemSettingsService, AI_CONFIG_PROVIDER],
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
||||
@@ -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 { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { type PlanTier } from '@prisma/client';
|
||||
import { DomainException, NotFoundException, ValidationException, PrismaService, LoggerService } from '@modules/shared';
|
||||
import { DomainException, NotFoundException, ValidationException, type PrismaService, type LoggerService } from '@modules/shared';
|
||||
import { SUBSCRIPTION_REPOSITORY, type ISubscriptionRepository } from '@modules/subscriptions';
|
||||
import { SubscriptionAdjustedEvent } from '../../../domain/events/subscription-adjusted.event';
|
||||
import { AdjustSubscriptionCommand } from './adjust-subscription.command';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { USER_REPOSITORY, type IUserRepository } from '@modules/auth';
|
||||
import { DomainException, NotFoundException, ValidationException, LoggerService } from '@modules/shared';
|
||||
import { DomainException, NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
|
||||
import { KycApprovedEvent } from '../../../domain/events/kyc-approved.event';
|
||||
import { ApproveKycCommand } from './approve-kyc.command';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { LISTING_REPOSITORY, type IListingRepository } from '@modules/listings';
|
||||
import { DomainException, NotFoundException, ValidationException, LoggerService } from '@modules/shared';
|
||||
import { DomainException, NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
|
||||
import { ListingApprovedEvent } from '../../../domain/events/listing-approved.event';
|
||||
import { ApproveListingCommand } from './approve-listing.command';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { USER_REPOSITORY, type IUserRepository } from '@modules/auth';
|
||||
import { DomainException, NotFoundException, ValidationException, LoggerService } from '@modules/shared';
|
||||
import { DomainException, NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
|
||||
import { UserBannedEvent } from '../../../domain/events/user-banned.event';
|
||||
import { UserUnbannedEvent } from '../../../domain/events/user-unbanned.event';
|
||||
import { BanUserCommand } from './ban-user.command';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { LISTING_REPOSITORY, type IListingRepository } from '@modules/listings';
|
||||
import { DomainException, ValidationException, LoggerService } from '@modules/shared';
|
||||
import { DomainException, ValidationException, type LoggerService } from '@modules/shared';
|
||||
import { ListingApprovedEvent } from '../../../domain/events/listing-approved.event';
|
||||
import { ListingRejectedEvent } from '../../../domain/events/listing-rejected.event';
|
||||
import { BulkModerateListingsCommand } from './bulk-moderate-listings.command';
|
||||
|
||||
@@ -14,5 +14,3 @@ export { RejectKycCommand } from './reject-kyc/reject-kyc.command';
|
||||
export { RejectKycHandler } from './reject-kyc/reject-kyc.handler';
|
||||
export { BulkModerateListingsCommand } from './bulk-moderate-listings/bulk-moderate-listings.command';
|
||||
export { BulkModerateListingsHandler } from './bulk-moderate-listings/bulk-moderate-listings.handler';
|
||||
export { UpdateAiSettingsCommand } from './update-ai-settings/update-ai-settings.command';
|
||||
export { UpdateAiSettingsHandler } from './update-ai-settings/update-ai-settings.handler';
|
||||
|
||||
@@ -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 { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { USER_REPOSITORY, type IUserRepository } from '@modules/auth';
|
||||
import { DomainException, NotFoundException, ValidationException, LoggerService } from '@modules/shared';
|
||||
import { DomainException, NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
|
||||
import { KycRejectedEvent } from '../../../domain/events/kyc-rejected.event';
|
||||
import { RejectKycCommand } from './reject-kyc.command';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { LISTING_REPOSITORY, type IListingRepository } from '@modules/listings';
|
||||
import { DomainException, NotFoundException, ValidationException, LoggerService } from '@modules/shared';
|
||||
import { DomainException, NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
|
||||
import { ListingRejectedEvent } from '../../../domain/events/listing-rejected.event';
|
||||
import { RejectListingCommand } from './reject-listing.command';
|
||||
|
||||
|
||||
@@ -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 { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { USER_REPOSITORY, type IUserRepository } from '@modules/auth';
|
||||
import { DomainException, NotFoundException, ValidationException, LoggerService } from '@modules/shared';
|
||||
import { DomainException, NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
|
||||
import { UserBannedEvent } from '../../../domain/events/user-banned.event';
|
||||
import { UserUnbannedEvent } from '../../../domain/events/user-unbanned.event';
|
||||
import { UpdateUserStatusCommand } from './update-user-status.command';
|
||||
|
||||
@@ -6,8 +6,7 @@ import {
|
||||
type PhoneChangeRequestedEvent,
|
||||
type PhoneChangedEvent,
|
||||
} from '@modules/auth';
|
||||
import { type ListingOwnershipTransferredEvent } from '@modules/listings';
|
||||
import { LoggerService } from '@modules/shared';
|
||||
import { type LoggerService } from '@modules/shared';
|
||||
import { type KycApprovedEvent } from '../../domain/events/kyc-approved.event';
|
||||
import { type KycRejectedEvent } from '../../domain/events/kyc-rejected.event';
|
||||
import { type ListingApprovedEvent } from '../../domain/events/listing-approved.event';
|
||||
@@ -75,25 +74,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 })
|
||||
|
||||
@@ -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 { CommandBus } from '@nestjs/cqrs';
|
||||
import { type CommandBus } from '@nestjs/cqrs';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { SendNotificationCommand } from '@modules/notifications';
|
||||
import { LoggerService, PrismaService } from '@modules/shared';
|
||||
import { type LoggerService, type PrismaService } from '@modules/shared';
|
||||
import { type UserBannedEvent } from '../../domain/events/user-banned.event';
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { type UserDeactivatedEvent } from '@modules/auth';
|
||||
import { LoggerService, PrismaService } from '@modules/shared';
|
||||
import { type LoggerService, type PrismaService } from '@modules/shared';
|
||||
|
||||
@Injectable()
|
||||
export class UserDeactivatedListener {
|
||||
|
||||
@@ -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 { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, LoggerService } from '@modules/shared';
|
||||
import { DomainException, type LoggerService } from '@modules/shared';
|
||||
import {
|
||||
AUDIT_LOG_REPOSITORY,
|
||||
type IAuditLogRepository,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, LoggerService } from '@modules/shared';
|
||||
import { DomainException, type LoggerService } from '@modules/shared';
|
||||
import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type DashboardStats } from '../../../domain/repositories/admin-query.repository';
|
||||
import { GetDashboardStatsQuery } from './get-dashboard-stats.query';
|
||||
|
||||
|
||||
@@ -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 { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, LoggerService } from '@modules/shared';
|
||||
import { DomainException, type LoggerService } from '@modules/shared';
|
||||
import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type KycQueueResult } from '../../../domain/repositories/admin-query.repository';
|
||||
import { GetKycQueueQuery } from './get-kyc-queue.query';
|
||||
|
||||
|
||||
@@ -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 { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, LoggerService } from '@modules/shared';
|
||||
import { DomainException, type LoggerService } from '@modules/shared';
|
||||
import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type ModerationQueueResult } from '../../../domain/repositories/admin-query.repository';
|
||||
import { GetModerationQueueQuery } from './get-moderation-queue.query';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, LoggerService } from '@modules/shared';
|
||||
import { DomainException, type LoggerService } from '@modules/shared';
|
||||
import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type RevenueStatsItem } from '../../../domain/repositories/admin-query.repository';
|
||||
import { GetRevenueStatsQuery } from './get-revenue-stats.query';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, NotFoundException, LoggerService } from '@modules/shared';
|
||||
import { DomainException, NotFoundException, type LoggerService } from '@modules/shared';
|
||||
import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type UserDetail } from '../../../domain/repositories/admin-query.repository';
|
||||
import { GetUserDetailQuery } from './get-user-detail.query';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, LoggerService } from '@modules/shared';
|
||||
import { DomainException, type LoggerService } from '@modules/shared';
|
||||
import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type UserListResult } from '../../../domain/repositories/admin-query.repository';
|
||||
import { GetUsersQuery } from './get-users.query';
|
||||
|
||||
|
||||
@@ -12,5 +12,3 @@ export { GetKycQueueQuery } from './get-kyc-queue/get-kyc-queue.query';
|
||||
export { GetKycQueueHandler } from './get-kyc-queue/get-kyc-queue.handler';
|
||||
export { GetAuditLogsQuery } from './get-audit-logs/get-audit-logs.query';
|
||||
export { GetAuditLogsHandler } from './get-audit-logs/get-audit-logs.handler';
|
||||
export { GetAiSettingsQuery } from './get-ai-settings/get-ai-settings.query';
|
||||
export { GetAiSettingsHandler, type AiSettingsDto } from './get-ai-settings/get-ai-settings.handler';
|
||||
|
||||
@@ -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 CreateAuditLogInput,
|
||||
} from './audit-log.repository';
|
||||
export {
|
||||
MODERATION_AUDIT_LOG_REPOSITORY,
|
||||
IModerationAuditLogRepository,
|
||||
type ModerationAction,
|
||||
type ModerationTargetType,
|
||||
type ModerationAuditLogEntry,
|
||||
type ModerationAuditLogListParams,
|
||||
type ModerationAuditLogListResult,
|
||||
type CreateModerationAuditLogInput,
|
||||
} from './moderation-audit-log.repository';
|
||||
|
||||
@@ -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 { SystemSettingsService } from './application/services/system-settings.service';
|
||||
export { ListingApprovedEvent } from './domain/events/listing-approved.event';
|
||||
export { ListingRejectedEvent } from './domain/events/listing-rejected.event';
|
||||
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 DashboardStats,
|
||||
@@ -44,76 +43,67 @@ export async function getDashboardStats(prisma: PrismaService): Promise<Dashboar
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simple in-process cache for revenue stats (TTL = 60 seconds)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RevenueCacheEntry {
|
||||
expiresAt: number;
|
||||
data: RevenueStatsItem[];
|
||||
}
|
||||
|
||||
const revenueStatsCache = new Map<string, RevenueCacheEntry>();
|
||||
|
||||
function buildCacheKey(startDate: Date, endDate: Date, groupBy: string): string {
|
||||
return `${startDate.toISOString()}|${endDate.toISOString()}|${groupBy}`;
|
||||
}
|
||||
|
||||
// Raw row returned by Postgres for the aggregation query
|
||||
interface RevenueRawRow {
|
||||
period: string;
|
||||
total_revenue: bigint;
|
||||
subscription_revenue: bigint;
|
||||
listing_fee_revenue: bigint;
|
||||
featured_listing_revenue: bigint;
|
||||
transaction_count: bigint;
|
||||
}
|
||||
|
||||
export async function getRevenueStats(
|
||||
prisma: PrismaService,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
groupBy: 'day' | 'month',
|
||||
): Promise<RevenueStatsItem[]> {
|
||||
const cacheKey = buildCacheKey(startDate, endDate, groupBy);
|
||||
const cached = revenueStatsCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.data;
|
||||
const payments = await prisma.payment.findMany({
|
||||
where: {
|
||||
status: 'COMPLETED',
|
||||
createdAt: { gte: startDate, lte: endDate },
|
||||
},
|
||||
select: {
|
||||
type: true,
|
||||
amountVND: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
const grouped = new Map<string, {
|
||||
totalRevenue: bigint;
|
||||
subscriptionRevenue: bigint;
|
||||
listingFeeRevenue: bigint;
|
||||
featuredListingRevenue: bigint;
|
||||
transactionCount: number;
|
||||
}>();
|
||||
|
||||
for (const payment of payments) {
|
||||
const period = groupBy === 'day'
|
||||
? payment.createdAt.toISOString().slice(0, 10)
|
||||
: payment.createdAt.toISOString().slice(0, 7);
|
||||
|
||||
if (!grouped.has(period)) {
|
||||
grouped.set(period, {
|
||||
totalRevenue: 0n,
|
||||
subscriptionRevenue: 0n,
|
||||
listingFeeRevenue: 0n,
|
||||
featuredListingRevenue: 0n,
|
||||
transactionCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const stats = grouped.get(period)!;
|
||||
stats.totalRevenue += payment.amountVND;
|
||||
stats.transactionCount++;
|
||||
|
||||
switch (payment.type) {
|
||||
case 'SUBSCRIPTION':
|
||||
stats.subscriptionRevenue += payment.amountVND;
|
||||
break;
|
||||
case 'LISTING_FEE':
|
||||
stats.listingFeeRevenue += payment.amountVND;
|
||||
break;
|
||||
case 'FEATURED_LISTING':
|
||||
stats.featuredListingRevenue += payment.amountVND;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Postgres can't prove that `DATE_TRUNC($n, ...)` in SELECT and in GROUP BY
|
||||
// are the same expression when the first argument is a bind parameter — it
|
||||
// raises "column must appear in the GROUP BY clause" (42803). Inline the
|
||||
// unit as a raw fragment instead. `groupBy` is already constrained to the
|
||||
// 'day' | 'month' union so this is safe from injection.
|
||||
const truncUnit = groupBy === 'day' ? Prisma.sql`'day'` : Prisma.sql`'month'`;
|
||||
|
||||
const rows = await prisma.$queryRaw<RevenueRawRow[]>`
|
||||
SELECT
|
||||
TO_CHAR(DATE_TRUNC(${truncUnit}, "createdAt"), 'YYYY-MM-DD') AS period,
|
||||
SUM("amountVND") AS total_revenue,
|
||||
SUM(CASE WHEN type = 'SUBSCRIPTION' THEN "amountVND" ELSE 0 END) AS subscription_revenue,
|
||||
SUM(CASE WHEN type = 'LISTING_FEE' THEN "amountVND" ELSE 0 END) AS listing_fee_revenue,
|
||||
SUM(CASE WHEN type = 'FEATURED_LISTING' THEN "amountVND" ELSE 0 END) AS featured_listing_revenue,
|
||||
COUNT(*) AS transaction_count
|
||||
FROM "Payment"
|
||||
WHERE status = 'COMPLETED'
|
||||
AND "createdAt" >= ${startDate}
|
||||
AND "createdAt" <= ${endDate}
|
||||
GROUP BY DATE_TRUNC(${truncUnit}, "createdAt")
|
||||
ORDER BY DATE_TRUNC(${truncUnit}, "createdAt") ASC
|
||||
`;
|
||||
|
||||
const data: RevenueStatsItem[] = rows.map((row) => ({
|
||||
period: row.period,
|
||||
totalRevenue: BigInt(row.total_revenue),
|
||||
subscriptionRevenue: BigInt(row.subscription_revenue),
|
||||
listingFeeRevenue: BigInt(row.listing_fee_revenue),
|
||||
featuredListingRevenue: BigInt(row.featured_listing_revenue),
|
||||
transactionCount: Number(row.transaction_count),
|
||||
return Array.from(grouped.entries()).map(([period, stats]) => ({
|
||||
period,
|
||||
...stats,
|
||||
}));
|
||||
|
||||
revenueStatsCache.set(cacheKey, { expiresAt: Date.now() + 60_000, data });
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export { PrismaAdminQueryRepository } from './prisma-admin-query.repository';
|
||||
export { PrismaAuditLogRepository } from './prisma-audit-log.repository';
|
||||
export { PrismaModerationAuditLogRepository } from './prisma-moderation-audit-log.repository';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '@modules/shared';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import {
|
||||
type IAdminQueryRepository,
|
||||
type ModerationQueueResult,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type AdminAction, type AuditTargetType, type Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '@modules/shared';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import {
|
||||
type IAuditLogRepository,
|
||||
type AuditLogEntry,
|
||||
|
||||
@@ -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,
|
||||
Controller,
|
||||
Get,
|
||||
Ip,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam, ApiQuery } from '@nestjs/swagger';
|
||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
||||
import {
|
||||
AdminFeatureListingCommand,
|
||||
type AdminFeatureListingResult,
|
||||
} from '@modules/listings';
|
||||
import { ApproveKycCommand } from '../../application/commands/approve-kyc/approve-kyc.command';
|
||||
import { type ApproveKycResult } from '../../application/commands/approve-kyc/approve-kyc.handler';
|
||||
import { ApproveListingCommand } from '../../application/commands/approve-listing/approve-listing.command';
|
||||
@@ -25,20 +19,17 @@ import { RejectKycCommand } from '../../application/commands/reject-kyc/reject-k
|
||||
import { type RejectKycResult } from '../../application/commands/reject-kyc/reject-kyc.handler';
|
||||
import { RejectListingCommand } from '../../application/commands/reject-listing/reject-listing.command';
|
||||
import { type RejectListingResult } from '../../application/commands/reject-listing/reject-listing.handler';
|
||||
import type { FlaggedListingsResult } from '../../application/queries/get-flagged-listings/get-flagged-listings.handler';
|
||||
import { GetFlaggedListingsQuery } from '../../application/queries/get-flagged-listings/get-flagged-listings.query';
|
||||
import { GetKycQueueQuery } from '../../application/queries/get-kyc-queue/get-kyc-queue.query';
|
||||
import { GetModerationQueueQuery } from '../../application/queries/get-moderation-queue/get-moderation-queue.query';
|
||||
import {
|
||||
type ModerationQueueResult,
|
||||
type KycQueueResult,
|
||||
} from '../../domain/repositories/admin-query.repository';
|
||||
import { AdminFeatureListingDto } from '../dto/admin-feature-listing.dto';
|
||||
import { ApproveKycDto } from '../dto/approve-kyc.dto';
|
||||
import { ApproveListingDto } from '../dto/approve-listing.dto';
|
||||
import { BulkModerateDto } from '../dto/bulk-moderate.dto';
|
||||
import { RejectKycDto } from '../dto/reject-kyc.dto';
|
||||
import { RejectListingDto } from '../dto/reject-listing.dto';
|
||||
import { type ApproveKycDto } from '../dto/approve-kyc.dto';
|
||||
import { type ApproveListingDto } from '../dto/approve-listing.dto';
|
||||
import { type BulkModerateDto } from '../dto/bulk-moderate.dto';
|
||||
import { type RejectKycDto } from '../dto/reject-kyc.dto';
|
||||
import { type RejectListingDto } from '../dto/reject-listing.dto';
|
||||
|
||||
@ApiTags('admin')
|
||||
@ApiBearerAuth('JWT')
|
||||
@@ -114,54 +105,6 @@ export class AdminModerationController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post('listings/:id/feature')
|
||||
@ApiOperation({
|
||||
summary: 'Admin: feature or unfeature a listing manually (audited, no payment)',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'Listing UUID' })
|
||||
@ApiResponse({ status: 201, description: 'Listing featured state updated successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Validation error' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized – missing or invalid JWT' })
|
||||
@ApiResponse({ status: 403, description: 'Forbidden – requires ADMIN role' })
|
||||
async adminFeatureListing(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: AdminFeatureListingDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Ip() ip: string,
|
||||
): Promise<AdminFeatureListingResult> {
|
||||
return this.commandBus.execute(
|
||||
new AdminFeatureListingCommand(
|
||||
id,
|
||||
user.sub,
|
||||
dto.action,
|
||||
dto.durationDays ?? null,
|
||||
dto.reason,
|
||||
ip ?? null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Flagged Listings (User Reports) ──
|
||||
|
||||
@Get('flagged-listings')
|
||||
@ApiOperation({ summary: 'Get listings flagged by users (báo cáo)' })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default 1)' })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default 20)' })
|
||||
@ApiResponse({ status: 200, description: 'Flagged listings queue retrieved successfully' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized – missing or invalid JWT' })
|
||||
@ApiResponse({ status: 403, description: 'Forbidden – requires ADMIN role' })
|
||||
async getFlaggedListings(
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
): Promise<FlaggedListingsResult> {
|
||||
return this.queryBus.execute(
|
||||
new GetFlaggedListingsQuery(
|
||||
page ? parseInt(page, 10) : 1,
|
||||
limit ? parseInt(limit, 10) : 20,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── KYC ──
|
||||
|
||||
@Get('kyc')
|
||||
|
||||
@@ -8,22 +8,15 @@ import {
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger';
|
||||
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
||||
import { AdjustSubscriptionCommand } from '../../application/commands/adjust-subscription/adjust-subscription.command';
|
||||
import { type AdjustSubscriptionResult } from '../../application/commands/adjust-subscription/adjust-subscription.handler';
|
||||
import { BanUserCommand } from '../../application/commands/ban-user/ban-user.command';
|
||||
import { type BanUserResult } from '../../application/commands/ban-user/ban-user.handler';
|
||||
import { ProvisionDeveloperCommand } from '../../application/commands/provision-developer/provision-developer.command';
|
||||
import { type ProvisionDeveloperResult } from '../../application/commands/provision-developer/provision-developer.handler';
|
||||
import { ProvisionParkOperatorCommand } from '../../application/commands/provision-park-operator/provision-park-operator.command';
|
||||
import { type ProvisionParkOperatorResult } from '../../application/commands/provision-park-operator/provision-park-operator.handler';
|
||||
import { UpdateAiSettingsCommand } from '../../application/commands/update-ai-settings/update-ai-settings.command';
|
||||
import { UpdateUserStatusCommand } from '../../application/commands/update-user-status/update-user-status.command';
|
||||
import { type UpdateUserStatusResult } from '../../application/commands/update-user-status/update-user-status.handler';
|
||||
import { type AiSettingsDto } from '../../application/queries/get-ai-settings/get-ai-settings.handler';
|
||||
import { GetAiSettingsQuery } from '../../application/queries/get-ai-settings/get-ai-settings.query';
|
||||
import { GetAuditLogsQuery } from '../../application/queries/get-audit-logs/get-audit-logs.query';
|
||||
import { GetDashboardStatsQuery } from '../../application/queries/get-dashboard-stats/get-dashboard-stats.query';
|
||||
import { GetRevenueStatsQuery } from '../../application/queries/get-revenue-stats/get-revenue-stats.query';
|
||||
@@ -36,15 +29,12 @@ import {
|
||||
type UserDetail,
|
||||
} from '../../domain/repositories/admin-query.repository';
|
||||
import { type AuditLogListResult } from '../../domain/repositories/audit-log.repository';
|
||||
import { AdjustSubscriptionDto } from '../dto/adjust-subscription.dto';
|
||||
import { BanUserDto } from '../dto/ban-user.dto';
|
||||
import { GetAuditLogsQueryDto } from '../dto/get-audit-logs-query.dto';
|
||||
import { GetUsersQueryDto } from '../dto/get-users-query.dto';
|
||||
import { ProvisionDeveloperDto } from '../dto/provision-developer.dto';
|
||||
import { ProvisionParkOperatorDto } from '../dto/provision-park-operator.dto';
|
||||
import { RevenueStatsDto } from '../dto/revenue-stats.dto';
|
||||
import { UpdateAiSettingsDto } from '../dto/update-ai-settings.dto';
|
||||
import { UpdateUserStatusDto } from '../dto/update-user-status.dto';
|
||||
import { type AdjustSubscriptionDto } from '../dto/adjust-subscription.dto';
|
||||
import { type BanUserDto } from '../dto/ban-user.dto';
|
||||
import { type GetAuditLogsQueryDto } from '../dto/get-audit-logs-query.dto';
|
||||
import { type GetUsersQueryDto } from '../dto/get-users-query.dto';
|
||||
import { type RevenueStatsDto } from '../dto/revenue-stats.dto';
|
||||
import { type UpdateUserStatusDto } from '../dto/update-user-status.dto';
|
||||
|
||||
@ApiTags('admin')
|
||||
@ApiBearerAuth('JWT')
|
||||
@@ -138,23 +128,7 @@ export class AdminController {
|
||||
|
||||
@Get('dashboard')
|
||||
@ApiOperation({ summary: 'Get admin dashboard statistics' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Dashboard stats retrieved successfully',
|
||||
schema: {
|
||||
example: {
|
||||
totalUsers: 12840,
|
||||
totalListings: 5432,
|
||||
activeListings: 4021,
|
||||
pendingModerationCount: 38,
|
||||
totalAgents: 612,
|
||||
verifiedAgents: 417,
|
||||
totalTransactions: 980,
|
||||
newUsersLast30Days: 246,
|
||||
newListingsLast30Days: 183,
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Dashboard stats retrieved successfully' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized – missing or invalid JWT' })
|
||||
@ApiResponse({ status: 403, description: 'Forbidden – requires ADMIN role' })
|
||||
async getDashboardStats(): Promise<DashboardStats> {
|
||||
@@ -181,83 +155,6 @@ export class AdminController {
|
||||
);
|
||||
}
|
||||
|
||||
// ── AI Settings ──
|
||||
|
||||
@Get('settings/ai')
|
||||
@ApiOperation({ summary: 'Get AI provider (Claude) settings' })
|
||||
@ApiResponse({ status: 200, description: 'AI settings retrieved successfully' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized – missing or invalid JWT' })
|
||||
@ApiResponse({ status: 403, description: 'Forbidden – requires ADMIN role' })
|
||||
async getAiSettings(): Promise<AiSettingsDto> {
|
||||
return this.queryBus.execute(new GetAiSettingsQuery());
|
||||
}
|
||||
|
||||
@Patch('settings/ai')
|
||||
@ApiOperation({ summary: 'Update AI provider (Claude) settings' })
|
||||
@ApiResponse({ status: 200, description: 'AI settings updated successfully' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized – missing or invalid JWT' })
|
||||
@ApiResponse({ status: 403, description: 'Forbidden – requires ADMIN role' })
|
||||
async updateAiSettings(
|
||||
@Body() dto: UpdateAiSettingsDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<AiSettingsDto> {
|
||||
return this.commandBus.execute(
|
||||
new UpdateAiSettingsCommand(user.sub, dto.apiUrl, dto.apiKey, dto.model),
|
||||
);
|
||||
}
|
||||
|
||||
// ── B2B Account Provisioning ──────────────────────────────────────
|
||||
|
||||
@Post('accounts/developers')
|
||||
@ApiOperation({
|
||||
summary: 'Tạo tài khoản CĐT (DEVELOPER) — admin only',
|
||||
description:
|
||||
'Tạo mới một user với role=DEVELOPER và tuỳ chọn gán quyền sở hữu các ProjectDevelopment hiện có. Dự án đã có owner khác sẽ bị từ chối.',
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Tạo tài khoản CĐT thành công' })
|
||||
@ApiResponse({ status: 400, description: 'Dữ liệu không hợp lệ' })
|
||||
@ApiResponse({ status: 401, description: 'Chưa xác thực' })
|
||||
@ApiResponse({ status: 403, description: 'Yêu cầu role ADMIN' })
|
||||
@ApiResponse({ status: 409, description: 'Số điện thoại/email đã tồn tại hoặc dự án đã có CĐT khác' })
|
||||
async provisionDeveloper(
|
||||
@Body() dto: ProvisionDeveloperDto,
|
||||
): Promise<ProvisionDeveloperResult> {
|
||||
return this.commandBus.execute(
|
||||
new ProvisionDeveloperCommand(
|
||||
dto.phone,
|
||||
dto.password,
|
||||
dto.fullName,
|
||||
dto.email ?? null,
|
||||
dto.projectIds ?? [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('accounts/park-operators')
|
||||
@ApiOperation({
|
||||
summary: 'Tạo tài khoản vận hành KCN (PARK_OPERATOR) — admin only',
|
||||
description:
|
||||
'Tạo mới một user với role=PARK_OPERATOR và tuỳ chọn gán quyền vận hành các IndustrialPark hiện có.',
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Tạo tài khoản PARK_OPERATOR thành công' })
|
||||
@ApiResponse({ status: 400, description: 'Dữ liệu không hợp lệ' })
|
||||
@ApiResponse({ status: 401, description: 'Chưa xác thực' })
|
||||
@ApiResponse({ status: 403, description: 'Yêu cầu role ADMIN' })
|
||||
@ApiResponse({ status: 409, description: 'Số điện thoại/email đã tồn tại hoặc KCN đã có đơn vị khác' })
|
||||
async provisionParkOperator(
|
||||
@Body() dto: ProvisionParkOperatorDto,
|
||||
): Promise<ProvisionParkOperatorResult> {
|
||||
return this.commandBus.execute(
|
||||
new ProvisionParkOperatorCommand(
|
||||
dto.phone,
|
||||
dto.password,
|
||||
dto.fullName,
|
||||
dto.email ?? null,
|
||||
dto.parkIds ?? [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Audit Logs ──
|
||||
|
||||
@Get('audit-logs')
|
||||
|
||||
@@ -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 { BulkModerateDto } from './bulk-moderate.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 { IsDateString, IsIn, IsOptional, registerDecorator, ValidationArguments, ValidationOptions } from 'class-validator';
|
||||
|
||||
function MaxDateRangeDays(maxDays: number, validationOptions?: ValidationOptions) {
|
||||
return function (object: object, propertyName: string) {
|
||||
registerDecorator({
|
||||
name: 'maxDateRangeDays',
|
||||
target: (object as { constructor: new (...args: unknown[]) => unknown }).constructor,
|
||||
propertyName,
|
||||
options: validationOptions,
|
||||
validator: {
|
||||
validate(_value: unknown, args: ValidationArguments) {
|
||||
const dto = args.object as RevenueStatsDto;
|
||||
if (!dto.startDate || !dto.endDate) return true;
|
||||
const start = new Date(dto.startDate);
|
||||
const end = new Date(dto.endDate);
|
||||
const diffMs = end.getTime() - start.getTime();
|
||||
const diffDays = diffMs / (1000 * 60 * 60 * 24);
|
||||
return diffDays >= 0 && diffDays <= maxDays;
|
||||
},
|
||||
defaultMessage(args: ValidationArguments) {
|
||||
return `Date range must not exceed ${(args.constraints as number[])[0]} days`;
|
||||
},
|
||||
},
|
||||
constraints: [maxDays],
|
||||
});
|
||||
};
|
||||
}
|
||||
import { IsDateString, IsIn, IsOptional } from 'class-validator';
|
||||
|
||||
export class RevenueStatsDto {
|
||||
@ApiProperty({ description: 'Start date (ISO 8601)', example: '2025-01-01' })
|
||||
@@ -34,7 +8,6 @@ export class RevenueStatsDto {
|
||||
|
||||
@ApiProperty({ description: 'End date (ISO 8601)', example: '2025-12-31' })
|
||||
@IsDateString()
|
||||
@MaxDateRangeDays(366, { message: 'Date range must not exceed 366 days' })
|
||||
endDate!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Group results by day or month', enum: ['day', 'month'], default: 'month' })
|
||||
|
||||
@@ -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 { CqrsModule } from '@nestjs/cqrs';
|
||||
import { RecalculateQualityScoreHandler } from './application/commands/recalculate-quality-score/recalculate-quality-score.handler';
|
||||
import { UpgradeToAgentHandler } from './application/commands/upgrade-to-agent/upgrade-to-agent.handler';
|
||||
import { ReviewEventsListener } from './application/listeners/review-events.listener';
|
||||
import { GetAgentDashboardHandler } from './application/queries/get-agent-dashboard/get-agent-dashboard.handler';
|
||||
import { GetAgentPublicProfileHandler } from './application/queries/get-agent-public-profile/get-agent-public-profile.handler';
|
||||
@@ -9,7 +8,7 @@ import { AGENT_REPOSITORY } from './domain/repositories/agent.repository';
|
||||
import { PrismaAgentRepository } from './infrastructure/repositories/prisma-agent.repository';
|
||||
import { AgentsController } from './presentation/controllers/agents.controller';
|
||||
|
||||
const CommandHandlers = [RecalculateQualityScoreHandler, UpgradeToAgentHandler];
|
||||
const CommandHandlers = [RecalculateQualityScoreHandler];
|
||||
|
||||
const QueryHandlers = [GetAgentDashboardHandler, GetAgentPublicProfileHandler];
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, LoggerService } from '@modules/shared';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, type LoggerService } from '@modules/shared';
|
||||
import {
|
||||
AGENT_REPOSITORY,
|
||||
type IAgentRepository,
|
||||
|
||||
@@ -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 { CommandBus } from '@nestjs/cqrs';
|
||||
import { type CommandBus } from '@nestjs/cqrs';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { LoggerService } from '@modules/shared';
|
||||
import { type LoggerService } from '@modules/shared';
|
||||
import { RecalculateQualityScoreCommand } from '../commands/recalculate-quality-score/recalculate-quality-score.command';
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, NotFoundException, LoggerService } from '@modules/shared';
|
||||
import { DomainException, NotFoundException, type LoggerService } from '@modules/shared';
|
||||
import {
|
||||
AGENT_REPOSITORY,
|
||||
type AgentDashboardData,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, LoggerService } from '@modules/shared';
|
||||
import { DomainException, type LoggerService } from '@modules/shared';
|
||||
import {
|
||||
AGENT_REPOSITORY,
|
||||
type AgentPublicProfileData,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '@modules/shared';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import { AgentEntity } from '../../domain/entities/agent.entity';
|
||||
import {
|
||||
type AgentDashboardData,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Body, Controller, Get, NotFoundException, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { Controller, Get, NotFoundException, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
@@ -15,12 +15,9 @@ import {
|
||||
Roles,
|
||||
} from '@modules/auth';
|
||||
import { RecalculateQualityScoreCommand } from '../../application/commands/recalculate-quality-score/recalculate-quality-score.command';
|
||||
import { UpgradeToAgentCommand } from '../../application/commands/upgrade-to-agent/upgrade-to-agent.command';
|
||||
import { type UpgradeToAgentResult } from '../../application/commands/upgrade-to-agent/upgrade-to-agent.handler';
|
||||
import { GetAgentDashboardQuery } from '../../application/queries/get-agent-dashboard/get-agent-dashboard.query';
|
||||
import { GetAgentPublicProfileQuery } from '../../application/queries/get-agent-public-profile/get-agent-public-profile.query';
|
||||
import { type AgentDashboardData, type AgentPublicProfileData } from '../../domain/repositories/agent.repository';
|
||||
import { UpgradeToAgentDto } from '../dto/upgrade-to-agent.dto';
|
||||
|
||||
@ApiTags('agents')
|
||||
@Controller('agents')
|
||||
@@ -62,29 +59,6 @@ export class AgentsController {
|
||||
return profile;
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Nâng cấp tài khoản lên đại lý' })
|
||||
@ApiResponse({ status: 201, description: 'Tài khoản đã được nâng cấp lên đại lý (chưa xác minh)' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 404, description: 'Không tìm thấy người dùng' })
|
||||
@ApiResponse({ status: 409, description: 'Tài khoản đã là đại lý hoặc không được phép nâng cấp' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('me/upgrade')
|
||||
async upgradeToAgent(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: UpgradeToAgentDto,
|
||||
): Promise<UpgradeToAgentResult> {
|
||||
return this.commandBus.execute(
|
||||
new UpgradeToAgentCommand(
|
||||
user.sub,
|
||||
dto.licenseNumber,
|
||||
dto.agency,
|
||||
dto.bio,
|
||||
dto.serviceAreas,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Recalculate quality score (admin/system)' })
|
||||
@ApiParam({ name: 'agentId', description: 'Agent ID' })
|
||||
|
||||
@@ -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,30 +1,17 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { makeCounterProvider, makeHistogramProvider } from '@willsoto/nestjs-prometheus';
|
||||
import { AdminModule } from '@modules/admin';
|
||||
import { ListingsModule } from '@modules/listings';
|
||||
import { ProjectsModule } from '@modules/projects';
|
||||
import { GenerateReportHandler } from './application/commands/generate-report/generate-report.handler';
|
||||
import { TrackEventHandler } from './application/commands/track-event/track-event.handler';
|
||||
import { UpdateMarketIndexHandler } from './application/commands/update-market-index/update-market-index.handler';
|
||||
import { ListingCreatedModerationHandler } from './application/event-handlers/listing-created-moderation.handler';
|
||||
import { BatchValuationHandler } from './application/queries/batch-valuation/batch-valuation.handler';
|
||||
import { IndustrialValuationHandler } from './application/queries/industrial-valuation/industrial-valuation.handler';
|
||||
import { GetDistrictStatsHandler } from './application/queries/get-district-stats/get-district-stats.handler';
|
||||
import { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap.handler';
|
||||
import { GetListingAiAdviceHandler } from './application/queries/get-listing-ai-advice/get-listing-ai-advice.handler';
|
||||
import { GetListingVolumeWardHandler } from './application/queries/get-listing-volume-ward/get-listing-volume-ward.handler';
|
||||
import { GetMarketHistoryHandler } from './application/queries/get-market-history/get-market-history.handler';
|
||||
import { GetMarketReportHandler } from './application/queries/get-market-report/get-market-report.handler';
|
||||
import { GetMarketSnapshotHandler } from './application/queries/get-market-snapshot/get-market-snapshot.handler';
|
||||
import { GetNearbyPOIsHandler } from './application/queries/get-nearby-pois/get-nearby-pois.handler';
|
||||
import { GetNeighborhoodScoreHandler } from './application/queries/get-neighborhood-score/get-neighborhood-score.handler';
|
||||
import { GetPriceMoversHandler } from './application/queries/get-price-movers/get-price-movers.handler';
|
||||
import { GetPriceTrendHandler } from './application/queries/get-price-trend/get-price-trend.handler';
|
||||
import { GetProjectAiAdviceHandler } from './application/queries/get-project-ai-advice/get-project-ai-advice.handler';
|
||||
import { GetTrendingAreasHandler } from './application/queries/get-trending-areas/get-trending-areas.handler';
|
||||
import { GetValuationHandler } from './application/queries/get-valuation/get-valuation.handler';
|
||||
import { IndustrialValuationHandler } from './application/queries/industrial-valuation/industrial-valuation.handler';
|
||||
import { PredictValuationHandler } from './application/queries/predict-valuation/predict-valuation.handler';
|
||||
import { ValuationComparisonHandler } from './application/queries/valuation-comparison/valuation-comparison.handler';
|
||||
import { ValuationExplanationHandler } from './application/queries/valuation-explanation/valuation-explanation.handler';
|
||||
import { ValuationHistoryHandler } from './application/queries/valuation-history/valuation-history.handler';
|
||||
@@ -37,17 +24,8 @@ import { PrismaValuationRepository } from './infrastructure/repositories/prisma-
|
||||
import { AI_SERVICE_CLIENT, AiServiceClient } from './infrastructure/services/ai-service.client';
|
||||
import { HttpAVMService } from './infrastructure/services/http-avm.service';
|
||||
import { MarketIndexCronService } from './infrastructure/services/market-index-cron.service';
|
||||
import {
|
||||
HttpNeighborhoodScoreService,
|
||||
PrismaNeighborhoodScoreService,
|
||||
} from './infrastructure/services/neighborhood-score.service';
|
||||
import { NeighborhoodScoreServiceImpl } from './infrastructure/services/neighborhood-score.service';
|
||||
import { PrismaAVMService } from './infrastructure/services/prisma-avm.service';
|
||||
import {
|
||||
RefreshMaterializedViewCronService,
|
||||
MATVIEW_REFRESH_TOTAL,
|
||||
MATVIEW_REFRESH_DURATION,
|
||||
MATVIEW_REFRESH_ERRORS,
|
||||
} from './infrastructure/services/refresh-materialized-view-cron.service';
|
||||
import { AnalyticsController } from './presentation/controllers/analytics.controller';
|
||||
import { AvmController } from './presentation/controllers/avm.controller';
|
||||
|
||||
@@ -59,25 +37,16 @@ const CommandHandlers = [
|
||||
|
||||
const QueryHandlers = [
|
||||
GetMarketReportHandler,
|
||||
GetMarketHistoryHandler,
|
||||
GetHeatmapHandler,
|
||||
GetListingVolumeWardHandler,
|
||||
GetPriceTrendHandler,
|
||||
GetDistrictStatsHandler,
|
||||
GetValuationHandler,
|
||||
PredictValuationHandler,
|
||||
BatchValuationHandler,
|
||||
ValuationHistoryHandler,
|
||||
ValuationComparisonHandler,
|
||||
ValuationExplanationHandler,
|
||||
GetNeighborhoodScoreHandler,
|
||||
GetNearbyPOIsHandler,
|
||||
IndustrialValuationHandler,
|
||||
GetListingAiAdviceHandler,
|
||||
GetProjectAiAdviceHandler,
|
||||
GetMarketSnapshotHandler,
|
||||
GetPriceMoversHandler,
|
||||
GetTrendingAreasHandler,
|
||||
];
|
||||
|
||||
const EventHandlers = [
|
||||
@@ -85,12 +54,7 @@ const EventHandlers = [
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
CqrsModule,
|
||||
forwardRef(() => ListingsModule),
|
||||
ProjectsModule,
|
||||
forwardRef(() => AdminModule), // for AI_CONFIG_PROVIDER (used by AI advice handlers)
|
||||
],
|
||||
imports: [CqrsModule],
|
||||
controllers: [AnalyticsController, AvmController],
|
||||
providers: [
|
||||
// AI service client
|
||||
@@ -104,31 +68,11 @@ const EventHandlers = [
|
||||
PrismaAVMService,
|
||||
{ provide: AVM_SERVICE, useClass: HttpAVMService },
|
||||
|
||||
// Neighborhood scoring: HTTP proxy → Python AI service, falls back to Prisma scoring
|
||||
PrismaNeighborhoodScoreService,
|
||||
{ provide: NEIGHBORHOOD_SCORE_SERVICE, useClass: HttpNeighborhoodScoreService },
|
||||
// Neighborhood scoring
|
||||
{ provide: NEIGHBORHOOD_SCORE_SERVICE, useClass: NeighborhoodScoreServiceImpl },
|
||||
|
||||
// Cron
|
||||
MarketIndexCronService,
|
||||
RefreshMaterializedViewCronService,
|
||||
|
||||
// Materialized-view refresh metrics
|
||||
makeCounterProvider({
|
||||
name: MATVIEW_REFRESH_TOTAL,
|
||||
help: 'Total materialized-view refresh attempts',
|
||||
labelNames: ['view', 'status'],
|
||||
}),
|
||||
makeHistogramProvider({
|
||||
name: MATVIEW_REFRESH_DURATION,
|
||||
help: 'Duration of materialized-view refresh in seconds',
|
||||
labelNames: ['view'],
|
||||
buckets: [1, 5, 15, 30, 60, 120, 300],
|
||||
}),
|
||||
makeCounterProvider({
|
||||
name: MATVIEW_REFRESH_ERRORS,
|
||||
help: 'Total materialized-view refresh errors',
|
||||
labelNames: ['view', 'reason'],
|
||||
}),
|
||||
|
||||
// CQRS
|
||||
...CommandHandlers,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { DomainException } from '@modules/shared';
|
||||
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||
import { DomainException } from '@modules/shared';
|
||||
import {
|
||||
type IAVMService,
|
||||
type BatchValuationResult,
|
||||
|
||||
@@ -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(),
|
||||
getMarketReport: vi.fn(),
|
||||
getHeatmap: vi.fn(),
|
||||
getHeatmapWard: vi.fn(),
|
||||
getListingVolumeByWard: vi.fn(),
|
||||
getPriceTrend: vi.fn(),
|
||||
getDistrictStats: vi.fn(),
|
||||
};
|
||||
const mockCache = { getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()) } as unknown as CacheService;
|
||||
handler = new GetHeatmapHandler(mockRepo as any, mockCache, { log: vi.fn(), error: vi.fn(), warn: vi.fn() } as any);
|
||||
handler = new GetHeatmapHandler(mockRepo as any, mockCache);
|
||||
});
|
||||
|
||||
it('returns heatmap data for a city and period', async () => {
|
||||
@@ -36,7 +34,6 @@ describe('GetHeatmapHandler', () => {
|
||||
|
||||
expect(result.city).toBe('Hồ Chí Minh');
|
||||
expect(result.period).toBe('2026-Q1');
|
||||
expect(result.level).toBe('district');
|
||||
expect(result.dataPoints).toEqual(dataPoints);
|
||||
expect(mockRepo.getHeatmap).toHaveBeenCalledWith('Hồ Chí Minh', '2026-Q1');
|
||||
});
|
||||
|
||||
@@ -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 { GetNeighborhoodScoreHandler } from '../queries/get-neighborhood-score/get-neighborhood-score.handler';
|
||||
import { GetNeighborhoodScoreQuery } from '../queries/get-neighborhood-score/get-neighborhood-score.query';
|
||||
@@ -20,21 +19,13 @@ const sampleScore: NeighborhoodScoreResult = {
|
||||
describe('GetNeighborhoodScoreHandler', () => {
|
||||
let handler: GetNeighborhoodScoreHandler;
|
||||
let mockService: { [K in keyof INeighborhoodScoreService]: ReturnType<typeof vi.fn> };
|
||||
let mockCache: { getOrSet: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockService = {
|
||||
getScore: vi.fn(),
|
||||
calculateAndSave: vi.fn(),
|
||||
};
|
||||
// Bypass cache: call the loader directly
|
||||
mockCache = {
|
||||
getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()),
|
||||
};
|
||||
handler = new GetNeighborhoodScoreHandler(
|
||||
mockService as any,
|
||||
mockCache as unknown as CacheService,
|
||||
);
|
||||
handler = new GetNeighborhoodScoreHandler(mockService as any);
|
||||
});
|
||||
|
||||
it('returns cached score when available', async () => {
|
||||
@@ -57,17 +48,4 @@ describe('GetNeighborhoodScoreHandler', () => {
|
||||
expect(mockService.getScore).toHaveBeenCalledWith('Quận 2', 'Hồ Chí Minh');
|
||||
expect(mockService.calculateAndSave).toHaveBeenCalledWith('Quận 2', 'Hồ Chí Minh');
|
||||
});
|
||||
|
||||
it('uses CacheService.getOrSet with 24h TTL', async () => {
|
||||
mockService.getScore.mockResolvedValue(sampleScore);
|
||||
|
||||
await handler.execute(new GetNeighborhoodScoreQuery('Quận 1', 'Hồ Chí Minh'));
|
||||
|
||||
expect(mockCache.getOrSet).toHaveBeenCalledWith(
|
||||
expect.stringContaining('neighborhood_score'),
|
||||
expect.any(Function),
|
||||
86400,
|
||||
'neighborhood-score',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 { type CacheService, type PrismaService, DomainException } from '@modules/shared';
|
||||
import { type CacheService, type PrismaService } from '@modules/shared';
|
||||
import { DomainException } from '@modules/shared';
|
||||
import { type IAVMService, type ValuationResult } from '../../domain/services/avm-service';
|
||||
import { ValuationComparisonHandler } from '../queries/valuation-comparison/valuation-comparison.handler';
|
||||
import { ValuationComparisonQuery } from '../queries/valuation-comparison/valuation-comparison.query';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { DomainException, NotFoundException } from '@modules/shared';
|
||||
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||
import { DomainException, NotFoundException } from '@modules/shared';
|
||||
import { ValuationEntity } from '../../domain/entities/valuation.entity';
|
||||
import { type IValuationRepository } from '../../domain/repositories/valuation.repository';
|
||||
import { ValuationExplanationHandler } from '../queries/valuation-explanation/valuation-explanation.handler';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user