Compare commits
120 Commits
5791c93e88
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c735f3097 | ||
|
|
38494a4bec | ||
|
|
b35ec55126 | ||
|
|
f82806e06d | ||
|
|
bb379b5c1b | ||
|
|
39156fc107 | ||
|
|
f112045826 | ||
|
|
dd67045e00 | ||
|
|
69ceb56316 | ||
|
|
5ed0993f74 | ||
|
|
388bc972c1 | ||
|
|
57cd84aebf | ||
|
|
1e9ef567a9 | ||
|
|
884a8d2a63 | ||
|
|
a9770a5f93 | ||
|
|
fba536406d | ||
|
|
73ff469126 | ||
|
|
1ae36f7f98 | ||
|
|
cec643ce5f | ||
|
|
416d1a5959 | ||
|
|
a38c797846 | ||
|
|
d6ac7c316f | ||
|
|
63a449ad9d | ||
|
|
c15bdcc6bf | ||
|
|
e7ca4fe8b1 | ||
|
|
b3143991ce | ||
|
|
99f305f6ba | ||
|
|
a7fb5295b8 | ||
|
|
58209b2434 | ||
|
|
405f2a3623 | ||
|
|
925863e471 | ||
|
|
b9a1a24f65 | ||
|
|
8825a13d1d | ||
|
|
54670b4bd4 | ||
|
|
f222611fcf | ||
|
|
489d61a27b | ||
|
|
7c5dd8d0b3 | ||
|
|
1332c759f5 | ||
|
|
abeb8fd322 | ||
|
|
89826858ac | ||
|
|
cc736e9137 | ||
|
|
a569765993 | ||
|
|
83659a4c8b | ||
|
|
3705193f97 | ||
|
|
7e655fd976 | ||
|
|
455c959f44 | ||
|
|
b2490e209e | ||
|
|
9af9e1d84a | ||
|
|
be47c26031 | ||
|
|
8026837edd | ||
|
|
03c1926d32 | ||
|
|
aed173adca | ||
|
|
fa3ba88f40 | ||
|
|
b4bb05479e | ||
|
|
d7c5b1ca2c | ||
|
|
0fc23b7ebd | ||
|
|
8a15df0bdb | ||
|
|
ec066dfa28 | ||
|
|
d7961e297c | ||
|
|
f5118244b7 | ||
|
|
1d26393f16 | ||
|
|
0168f1f6f5 | ||
|
|
dfb398131d | ||
|
|
6b23bfb756 | ||
|
|
2788b35108 | ||
|
|
5a119df806 | ||
|
|
7d26436461 | ||
|
|
199de240b1 | ||
|
|
8681eb9aa9 | ||
|
|
7a854373b3 | ||
|
|
36a9b00cf1 | ||
|
|
0329455e9a | ||
|
|
94d462ef4f | ||
|
|
4be5eb90a4 | ||
|
|
05be5f4467 | ||
|
|
8706fff92f | ||
|
|
23af73496d | ||
|
|
7e2ccdfb7c | ||
|
|
e798468e4c | ||
|
|
c478abae38 | ||
|
|
ee6d6d4c17 | ||
|
|
65bd641e1f | ||
|
|
81ae59cb9d | ||
|
|
1d4cb749e2 | ||
|
|
3a9e44758c | ||
|
|
1668c800fe | ||
|
|
566ad75c0e | ||
|
|
08b96f9c2d | ||
|
|
912121cf09 | ||
|
|
53580d444b | ||
|
|
846ea652d8 | ||
|
|
ceab711dc6 | ||
|
|
ef1bdcad1c | ||
|
|
7b6e99edef | ||
|
|
0df087b372 | ||
|
|
4c09d82989 | ||
|
|
b82c4548f8 | ||
|
|
72aa7aab57 | ||
|
|
59165a1a9f | ||
|
|
0676b8c7f2 | ||
|
|
ecb217cf5e | ||
|
|
f7bb0c0dff | ||
|
|
606fa0bd4e | ||
|
|
e2e748f0c7 | ||
|
|
a720825257 | ||
|
|
603ef7db86 | ||
|
|
66f952a4a8 | ||
|
|
9cefd439db | ||
|
|
27ba8412e1 | ||
|
|
7d6fcb4d8d | ||
|
|
e1beda2573 | ||
|
|
805aaeffad | ||
|
|
f7b0fe6f5d | ||
|
|
0651074319 | ||
|
|
a70db64da1 | ||
|
|
641e91f4d4 | ||
|
|
bcd8b6685a | ||
|
|
d91e3f6fe2 | ||
|
|
d6d7584677 | ||
|
|
d07f39b864 |
@@ -5,13 +5,21 @@
|
||||
"name": "web",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["--filter", "@goodgo/web", "dev"],
|
||||
"port": 3000
|
||||
"port": 3200
|
||||
},
|
||||
{
|
||||
"name": "api",
|
||||
"runtimeExecutable": "env",
|
||||
"runtimeArgs": ["NODE_OPTIONS=-r dotenv/config", "DOTENV_CONFIG_PATH=../../.env", "pnpm", "--filter", "@goodgo/api", "dev"],
|
||||
"port": 3001
|
||||
"runtimeArgs": [
|
||||
"NODE_OPTIONS=-r dotenv/config",
|
||||
"DOTENV_CONFIG_PATH=../../.env",
|
||||
"PORT=3201",
|
||||
"pnpm",
|
||||
"--filter",
|
||||
"@goodgo/api",
|
||||
"dev"
|
||||
],
|
||||
"port": 3201
|
||||
},
|
||||
{
|
||||
"name": "ai-services",
|
||||
|
||||
61
.env.example
61
.env.example
@@ -32,6 +32,19 @@ 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
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -78,6 +91,15 @@ JWT_EXPIRES_IN=15m
|
||||
JWT_REFRESH_SECRET=<generate with: openssl rand -base64 48>
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Seed / E2E Accounts
|
||||
# -----------------------------------------------------------------------------
|
||||
# Required when running `pnpm db:seed`. Use a local/test-only value.
|
||||
# Do not reuse this password for any real production admin account.
|
||||
SEED_DEFAULT_PASSWORD=
|
||||
BCRYPT_ROUNDS=12
|
||||
E2E_ADMIN_PHONE=0876677771
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OAuth Providers
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -97,11 +119,19 @@ FRONTEND_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3000
|
||||
WEB_PORT=3001
|
||||
|
||||
# Demo accounts must stay disabled in production. To enable in a local demo,
|
||||
# provide a JSON array of {phone,name,role,badgeClass} and a temporary password.
|
||||
NEXT_PUBLIC_ENABLE_DEMO_ACCOUNTS=false
|
||||
NEXT_PUBLIC_DEMO_PASSWORD=
|
||||
NEXT_PUBLIC_DEMO_ACCOUNTS=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# AI Service (Python/FastAPI)
|
||||
# -----------------------------------------------------------------------------
|
||||
AI_SERVICE_PORT=8000
|
||||
AI_SERVICE_URL=http://localhost:8000
|
||||
AI_SERVICE_API_KEY=<optional-in-dev-required-in-prod>
|
||||
AI_CORS_ORIGINS=http://localhost:3000,http://localhost:3001
|
||||
CLAUDE_API_KEY=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -111,23 +141,47 @@ NEXT_PUBLIC_MAPBOX_TOKEN=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Payment Gateways (VNPay, MoMo, ZaloPay)
|
||||
# Leave empty if not using payment features
|
||||
# Leave empty if not using payment features.
|
||||
#
|
||||
# IMPORTANT: The values below default to SANDBOX endpoints. When deploying
|
||||
# with NODE_ENV=production, swap each *_BASE_URL / *_ENDPOINT to the
|
||||
# production URL and set *_TMN_CODE / *_PARTNER_CODE / *_APP_ID / secret
|
||||
# values to live merchant credentials issued by the gateway. See
|
||||
# docs/payment-go-live-checklist.md for the full cutover procedure.
|
||||
# The API logs a startup warning if production mode is detected with
|
||||
# sandbox-looking credentials.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# VNPay — sandbox by default
|
||||
# Production: VNPAY_BASE_URL=https://pay.vnpay.vn/vpcpay.html
|
||||
# Production: VNPAY_API_URL=https://merchant.vnpay.vn/merchant_webapi/api/transaction
|
||||
VNPAY_TMN_CODE=
|
||||
VNPAY_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=
|
||||
@@ -184,7 +238,10 @@ SENTRY_PROJECT=
|
||||
# Must be exactly 64 hex characters (32 bytes).
|
||||
# openssl rand -hex 32
|
||||
# -----------------------------------------------------------------------------
|
||||
KYC_ENCRYPTION_KEY=<generate with: openssl rand -hex 32>
|
||||
FIELD_ENCRYPTION_KEY=<generate with: openssl rand -hex 32>
|
||||
FIELD_ENCRYPTION_KEY_VERSION=1
|
||||
# Backward-compatible fallback accepted by the API; prefer FIELD_ENCRYPTION_KEY.
|
||||
KYC_ENCRYPTION_KEY=
|
||||
KYC_ENCRYPTION_KEY_VERSION=1
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -51,6 +51,10 @@ CORS_ORIGINS=http://localhost:3010,http://localhost:3000
|
||||
# Bcrypt (fast rounds for test — production uses 12+)
|
||||
BCRYPT_ROUNDS=4
|
||||
|
||||
# Seeded admin used by E2E happy-path admin flows
|
||||
SEED_DEFAULT_PASSWORD=Test@1234!
|
||||
E2E_ADMIN_PHONE=0876677771
|
||||
|
||||
# OAuth (test stubs)
|
||||
GOOGLE_CLIENT_ID=test-google-client-id
|
||||
GOOGLE_CLIENT_SECRET=test-google-client-secret
|
||||
@@ -70,3 +74,8 @@ MOMO_SECRET_KEY=TEST_MOMO_SECRET_KEY
|
||||
ZALOPAY_APP_ID=TEST_ZALOPAY_APP
|
||||
ZALOPAY_KEY1=TEST_ZALOPAY_KEY1
|
||||
ZALOPAY_KEY2=TEST_ZALOPAY_KEY2
|
||||
BANK_TRANSFER_ACCOUNT_NUMBER=0123456789
|
||||
BANK_TRANSFER_BANK_NAME=Vietcombank
|
||||
BANK_TRANSFER_ACCOUNT_HOLDER=CONG_TY_GOODGO
|
||||
BANK_TRANSFER_WEBHOOK_SECRET=test-bank-transfer-webhook-secret-minimum-32-chars
|
||||
BANK_TRANSFER_INSTRUCTIONS_URL=http://localhost:3010/thanh-toan/chuyen-khoan
|
||||
|
||||
83
.github/workflows/ci.yml
vendored
83
.github/workflows/ci.yml
vendored
@@ -149,79 +149,10 @@ jobs:
|
||||
name: E2E Tests
|
||||
needs: ci
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgis/postgis:16-3.4
|
||||
env:
|
||||
POSTGRES_DB: goodgo_test
|
||||
POSTGRES_USER: goodgo
|
||||
POSTGRES_PASSWORD: goodgo_test_secret
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U goodgo -d goodgo_test"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
--health-start-period 30s
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
typesense:
|
||||
image: typesense/typesense:27.1
|
||||
ports:
|
||||
- 8108:8108
|
||||
env:
|
||||
TYPESENSE_API_KEY: ts_ci_key
|
||||
TYPESENSE_DATA_DIR: /data
|
||||
options: >-
|
||||
--health-cmd "curl -sf http://localhost:8108/health || exit 1"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
ports:
|
||||
- 9000:9000
|
||||
env:
|
||||
MINIO_ROOT_USER: ci_minio_user
|
||||
MINIO_ROOT_PASSWORD: ci_minio_secret_key_32chars!!
|
||||
options: >-
|
||||
--health-cmd "curl -sf http://localhost:9000/minio/health/live || exit 1"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
timeout-minutes: 45
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://goodgo:goodgo_test_secret@localhost:5432/goodgo_test
|
||||
REDIS_URL: redis://localhost:6379
|
||||
TYPESENSE_URL: http://localhost:8108
|
||||
TYPESENSE_HOST: localhost
|
||||
TYPESENSE_PORT: 8108
|
||||
TYPESENSE_API_KEY: ts_ci_key
|
||||
MINIO_ENDPOINT: localhost
|
||||
MINIO_PORT: 9000
|
||||
MINIO_ACCESS_KEY: ci_minio_user
|
||||
MINIO_SECRET_KEY: ci_minio_secret_key_32chars!!
|
||||
MINIO_BUCKET: goodgo-uploads
|
||||
NODE_ENV: test
|
||||
JWT_SECRET: e2e-test-jwt-secret-key
|
||||
JWT_REFRESH_SECRET: e2e-test-refresh-secret-key
|
||||
VNPAY_TMN_CODE: TESTCODE
|
||||
VNPAY_HASH_SECRET: TESTHASHSECRET
|
||||
VNPAY_URL: https://sandbox.vnpayment.vn/paymentv2/vpcpay.html
|
||||
VNPAY_RETURN_URL: http://localhost:3000/payment/return
|
||||
CI: true
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -239,6 +170,12 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Load E2E environment
|
||||
run: awk 'NF && $1 !~ /^#/' .env.test >> "$GITHUB_ENV"
|
||||
|
||||
- name: Start CI service stack
|
||||
run: docker compose --env-file .env.ci -f docker-compose.ci.yml up -d --wait
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v4
|
||||
@@ -281,3 +218,7 @@ jobs:
|
||||
name: playwright-traces
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Stop CI service stack
|
||||
if: always()
|
||||
run: docker compose --env-file .env.ci -f docker-compose.ci.yml down -v
|
||||
|
||||
61
.github/workflows/codeql.yml
vendored
61
.github/workflows/codeql.yml
vendored
@@ -1,61 +0,0 @@
|
||||
name: CodeQL Analysis
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
schedule:
|
||||
# Run weekly on Monday at 06:17 UTC — off-peak to avoid :00/:30 congestion
|
||||
- cron: "17 6 * * 1"
|
||||
|
||||
concurrency:
|
||||
group: codeql-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: CodeQL (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [javascript-typescript]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# Use extended security queries for deeper analysis
|
||||
queries: security-extended,security-and-quality
|
||||
config: |
|
||||
paths:
|
||||
- apps/
|
||||
- libs/
|
||||
paths-ignore:
|
||||
- node_modules/
|
||||
- "**/dist/"
|
||||
- "**/*.spec.ts"
|
||||
- "**/*.test.ts"
|
||||
- "**/__tests__/"
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
# SARIF results are automatically uploaded to GitHub Security tab
|
||||
upload: always
|
||||
93
.github/workflows/deploy.yml
vendored
93
.github/workflows/deploy.yml
vendored
@@ -23,6 +23,53 @@ env:
|
||||
REGISTRY_URL: ghcr.io/${{ github.repository_owner }}
|
||||
|
||||
jobs:
|
||||
deploy-config:
|
||||
name: Check Deploy Configuration
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
staging_ready: ${{ steps.check.outputs.staging_ready }}
|
||||
production_ready: ${{ steps.check.outputs.production_ready }}
|
||||
|
||||
steps:
|
||||
- name: Check required deploy secrets
|
||||
id: check
|
||||
env:
|
||||
TARGET_ENV: ${{ inputs.environment }}
|
||||
STAGING_HOST: ${{ secrets.STAGING_HOST }}
|
||||
STAGING_USER: ${{ secrets.STAGING_USER }}
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
STAGING_URL: ${{ secrets.STAGING_URL }}
|
||||
STAGING_API_URL: ${{ secrets.STAGING_API_URL }}
|
||||
PRODUCTION_HOST: ${{ secrets.PRODUCTION_HOST }}
|
||||
PRODUCTION_USER: ${{ secrets.PRODUCTION_USER }}
|
||||
PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
|
||||
PRODUCTION_URL: ${{ secrets.PRODUCTION_URL }}
|
||||
PRODUCTION_API_URL: ${{ secrets.PRODUCTION_API_URL }}
|
||||
run: |
|
||||
STAGING_READY=false
|
||||
PRODUCTION_READY=false
|
||||
|
||||
if [ -n "$STAGING_HOST" ] && [ -n "$STAGING_USER" ] && [ -n "$STAGING_SSH_KEY" ] && [ -n "$STAGING_URL" ] && [ -n "$STAGING_API_URL" ]; then
|
||||
STAGING_READY=true
|
||||
fi
|
||||
|
||||
if [ -n "$PRODUCTION_HOST" ] && [ -n "$PRODUCTION_USER" ] && [ -n "$PRODUCTION_SSH_KEY" ] && [ -n "$PRODUCTION_URL" ] && [ -n "$PRODUCTION_API_URL" ]; then
|
||||
PRODUCTION_READY=true
|
||||
fi
|
||||
|
||||
echo "staging_ready=$STAGING_READY" >> "$GITHUB_OUTPUT"
|
||||
echo "production_ready=$PRODUCTION_READY" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ "$TARGET_ENV" = "staging" ] && [ "$STAGING_READY" != "true" ]; then
|
||||
echo "Missing required staging deploy secrets; configure STAGING_HOST, STAGING_USER, STAGING_SSH_KEY, STAGING_URL, and STAGING_API_URL."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ "$TARGET_ENV" = "production" ] && [ "$PRODUCTION_READY" != "true" ]; then
|
||||
echo "Missing required production deploy secrets; configure PRODUCTION_HOST, PRODUCTION_USER, PRODUCTION_SSH_KEY, PRODUCTION_URL, and PRODUCTION_API_URL."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-api:
|
||||
name: Build API Image
|
||||
runs-on: ubuntu-latest
|
||||
@@ -154,11 +201,14 @@ jobs:
|
||||
|
||||
deploy-staging:
|
||||
name: Deploy to Staging
|
||||
needs: [build-api, build-web, build-ai]
|
||||
needs: [deploy-config, build-api, build-web, build-ai]
|
||||
if: >-
|
||||
github.ref == 'refs/heads/develop' ||
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/master') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.environment == 'staging')
|
||||
needs.deploy-config.outputs.staging_ready == 'true' &&
|
||||
(
|
||||
github.ref == 'refs/heads/develop' ||
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/master') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.environment == 'staging')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
environment: staging
|
||||
|
||||
@@ -221,17 +271,17 @@ jobs:
|
||||
[ "\$PREV_WEB" != "none" ] && docker tag "\$PREV_WEB" goodgo-web:rollback 2>/dev/null || true
|
||||
[ "\$PREV_AI" != "none" ] && docker tag "\$PREV_AI" goodgo-ai-services:rollback 2>/dev/null || true
|
||||
|
||||
# Pull new images
|
||||
docker compose -f docker-compose.prod.yml pull api web ai-services
|
||||
|
||||
# Apply migrations with the newly pulled API image before switching app containers.
|
||||
docker compose -f docker-compose.prod.yml run --rm --no-deps api \
|
||||
npx prisma migrate deploy --schema /app/prisma/schema.prisma
|
||||
|
||||
# Rolling update — zero downtime
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --wait api
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --wait web
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --wait ai-services
|
||||
|
||||
# Run database migrations
|
||||
docker compose -f docker-compose.prod.yml exec -T api npx prisma migrate deploy
|
||||
|
||||
# NOTE: docker image prune is NOT run here — it runs after smoke tests pass
|
||||
DEPLOY_SCRIPT
|
||||
|
||||
@@ -394,8 +444,11 @@ jobs:
|
||||
|
||||
rollback-staging:
|
||||
name: Rollback Staging
|
||||
needs: [deploy-staging, smoke-test-staging]
|
||||
if: failure()
|
||||
needs: [deploy-config, deploy-staging, smoke-test-staging]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.deploy-config.outputs.staging_ready == 'true' &&
|
||||
(needs.deploy-staging.result == 'failure' || needs.smoke-test-staging.result == 'failure')
|
||||
runs-on: ubuntu-latest
|
||||
environment: staging
|
||||
|
||||
@@ -462,8 +515,11 @@ jobs:
|
||||
|
||||
deploy-production:
|
||||
name: Deploy to Production
|
||||
needs: [build-api, build-web, build-ai]
|
||||
if: inputs.environment == 'production'
|
||||
needs: [deploy-config, build-api, build-web, build-ai]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' &&
|
||||
inputs.environment == 'production' &&
|
||||
needs.deploy-config.outputs.production_ready == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
|
||||
@@ -507,13 +563,15 @@ jobs:
|
||||
|
||||
docker compose -f docker-compose.prod.yml pull api web ai-services
|
||||
|
||||
# Apply migrations with the newly pulled API image before switching app containers.
|
||||
docker compose -f docker-compose.prod.yml run --rm --no-deps api \
|
||||
npx prisma migrate deploy --schema /app/prisma/schema.prisma
|
||||
|
||||
# Rolling update with health checks
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --wait api
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --wait web
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --wait ai-services
|
||||
|
||||
docker compose -f docker-compose.prod.yml exec -T api npx prisma migrate deploy
|
||||
|
||||
# NOTE: docker image prune is NOT run here — it runs after smoke tests pass
|
||||
DEPLOY_SCRIPT
|
||||
|
||||
@@ -652,8 +710,11 @@ jobs:
|
||||
|
||||
rollback-production:
|
||||
name: Rollback Production
|
||||
needs: [smoke-test-production]
|
||||
if: failure()
|
||||
needs: [deploy-config, deploy-production, smoke-test-production]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.deploy-config.outputs.production_ready == 'true' &&
|
||||
(needs.deploy-production.result == 'failure' || needs.smoke-test-production.result == 'failure')
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
|
||||
|
||||
100
.github/workflows/e2e.yml
vendored
100
.github/workflows/e2e.yml
vendored
@@ -14,98 +14,10 @@ jobs:
|
||||
e2e:
|
||||
name: Playwright E2E
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgis/postgis:16-3.4
|
||||
env:
|
||||
POSTGRES_DB: goodgo_test
|
||||
POSTGRES_USER: goodgo
|
||||
POSTGRES_PASSWORD: goodgo_test_secret
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U goodgo -d goodgo_test"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
--health-start-period 30s
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
typesense:
|
||||
image: typesense/typesense:27.1
|
||||
ports:
|
||||
- 8108:8108
|
||||
env:
|
||||
TYPESENSE_API_KEY: ts_ci_key
|
||||
TYPESENSE_DATA_DIR: /data
|
||||
options: >-
|
||||
--health-cmd "curl -sf http://localhost:8108/health || exit 1"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
ports:
|
||||
- 9000:9000
|
||||
env:
|
||||
MINIO_ROOT_USER: ${{ vars.CI_MINIO_ACCESS_KEY || 'ci_minio_user' }}
|
||||
MINIO_ROOT_PASSWORD: ${{ vars.CI_MINIO_SECRET_KEY || 'ci_minio_secret_key_32chars!!' }}
|
||||
options: >-
|
||||
--health-cmd "curl -sf http://localhost:9000/minio/health/live || exit 1"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
timeout-minutes: 45
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://goodgo:goodgo_test_secret@localhost:5432/goodgo_test
|
||||
REDIS_URL: redis://localhost:6379
|
||||
REDIS_HOST: localhost
|
||||
REDIS_PORT: 6379
|
||||
TYPESENSE_URL: http://localhost:8108
|
||||
TYPESENSE_HOST: localhost
|
||||
TYPESENSE_PORT: 8108
|
||||
TYPESENSE_PROTOCOL: http
|
||||
TYPESENSE_API_KEY: ts_ci_key
|
||||
MINIO_ENDPOINT: localhost
|
||||
MINIO_PORT: 9000
|
||||
MINIO_ACCESS_KEY: ${{ vars.CI_MINIO_ACCESS_KEY || 'ci_minio_user' }}
|
||||
MINIO_SECRET_KEY: ${{ vars.CI_MINIO_SECRET_KEY || 'ci_minio_secret_key_32chars!!' }}
|
||||
MINIO_BUCKET: goodgo-uploads
|
||||
NODE_ENV: test
|
||||
CI: true
|
||||
# API and Web ports for Playwright webServer
|
||||
API_PORT: 3001
|
||||
WEB_PORT: 3000
|
||||
API_BASE_URL: http://localhost:3001/api/v1/
|
||||
WEB_BASE_URL: http://localhost:3000
|
||||
NEXT_PUBLIC_API_URL: http://localhost:3001/api/v1
|
||||
JWT_SECRET: e2e-test-jwt-secret-key-minimum-32-chars-long-enough
|
||||
JWT_REFRESH_SECRET: e2e-test-refresh-secret-key-minimum-32-chars-ok
|
||||
JWT_EXPIRES_IN: 15m
|
||||
JWT_REFRESH_EXPIRES_IN: 7d
|
||||
BCRYPT_ROUNDS: 4
|
||||
VNPAY_TMN_CODE: TESTCODE
|
||||
VNPAY_HASH_SECRET: TESTHASHSECRETTESTHASHSECRETTEST
|
||||
VNPAY_URL: https://sandbox.vnpayment.vn/paymentv2/vpcpay.html
|
||||
VNPAY_RETURN_URL: http://localhost:3000/payment/return
|
||||
GOOGLE_CLIENT_ID: test-google-client-id
|
||||
GOOGLE_CLIENT_SECRET: test-google-client-secret
|
||||
GOOGLE_CALLBACK_URL: http://localhost:3001/api/v1/auth/google/callback
|
||||
ZALO_APP_ID: test-zalo-app-id
|
||||
ZALO_APP_SECRET: test-zalo-app-secret
|
||||
ZALO_CALLBACK_URL: http://localhost:3001/api/v1/auth/zalo/callback
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -123,6 +35,12 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Load E2E environment
|
||||
run: awk 'NF && $1 !~ /^#/' .env.test >> "$GITHUB_ENV"
|
||||
|
||||
- name: Start CI service stack
|
||||
run: docker compose --env-file .env.ci -f docker-compose.ci.yml up -d --wait
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v4
|
||||
@@ -165,3 +83,7 @@ jobs:
|
||||
name: playwright-traces
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Stop CI service stack
|
||||
if: always()
|
||||
run: docker compose --env-file .env.ci -f docker-compose.ci.yml down -v
|
||||
|
||||
76
.github/workflows/security.yml
vendored
76
.github/workflows/security.yml
vendored
@@ -15,7 +15,6 @@ concurrency:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
# ── Dependency Audit ─────────────────────────────────────────────
|
||||
@@ -96,25 +95,8 @@ jobs:
|
||||
cache-from: type=gha,scope=api-scan
|
||||
cache-to: type=gha,mode=max,scope=api-scan
|
||||
|
||||
- name: Run Trivy vulnerability scanner (API)
|
||||
uses: aquasecurity/trivy-action@0.28.0
|
||||
with:
|
||||
image-ref: "goodgo-api:scan"
|
||||
format: "sarif"
|
||||
output: "trivy-api-results.sarif"
|
||||
severity: "CRITICAL,HIGH"
|
||||
# Ignore unfixed vulns to reduce noise
|
||||
ignore-unfixed: true
|
||||
|
||||
- name: Upload Trivy SARIF (API)
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: "trivy-api-results.sarif"
|
||||
category: "trivy-api"
|
||||
|
||||
- name: Trivy table output (API)
|
||||
uses: aquasecurity/trivy-action@0.28.0
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
with:
|
||||
image-ref: "goodgo-api:scan"
|
||||
format: "table"
|
||||
@@ -144,24 +126,8 @@ jobs:
|
||||
cache-from: type=gha,scope=web-scan
|
||||
cache-to: type=gha,mode=max,scope=web-scan
|
||||
|
||||
- name: Run Trivy vulnerability scanner (Web)
|
||||
uses: aquasecurity/trivy-action@0.28.0
|
||||
with:
|
||||
image-ref: "goodgo-web:scan"
|
||||
format: "sarif"
|
||||
output: "trivy-web-results.sarif"
|
||||
severity: "CRITICAL,HIGH"
|
||||
ignore-unfixed: true
|
||||
|
||||
- name: Upload Trivy SARIF (Web)
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: "trivy-web-results.sarif"
|
||||
category: "trivy-web"
|
||||
|
||||
- name: Trivy table output (Web)
|
||||
uses: aquasecurity/trivy-action@0.28.0
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
with:
|
||||
image-ref: "goodgo-web:scan"
|
||||
format: "table"
|
||||
@@ -191,24 +157,8 @@ jobs:
|
||||
cache-from: type=gha,scope=ai-scan
|
||||
cache-to: type=gha,mode=max,scope=ai-scan
|
||||
|
||||
- name: Run Trivy vulnerability scanner (AI)
|
||||
uses: aquasecurity/trivy-action@0.28.0
|
||||
with:
|
||||
image-ref: "goodgo-ai:scan"
|
||||
format: "sarif"
|
||||
output: "trivy-ai-results.sarif"
|
||||
severity: "CRITICAL,HIGH"
|
||||
ignore-unfixed: true
|
||||
|
||||
- name: Upload Trivy SARIF (AI)
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: "trivy-ai-results.sarif"
|
||||
category: "trivy-ai"
|
||||
|
||||
- name: Trivy table output (AI)
|
||||
uses: aquasecurity/trivy-action@0.28.0
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
with:
|
||||
image-ref: "goodgo-ai:scan"
|
||||
format: "table"
|
||||
@@ -225,26 +175,8 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run Trivy filesystem scanner
|
||||
uses: aquasecurity/trivy-action@0.28.0
|
||||
with:
|
||||
scan-type: "fs"
|
||||
scan-ref: "."
|
||||
format: "sarif"
|
||||
output: "trivy-fs-results.sarif"
|
||||
severity: "CRITICAL,HIGH"
|
||||
ignore-unfixed: true
|
||||
scanners: "vuln,secret,misconfig"
|
||||
|
||||
- name: Upload Trivy SARIF (filesystem)
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: "trivy-fs-results.sarif"
|
||||
category: "trivy-filesystem"
|
||||
|
||||
- name: Trivy filesystem table output
|
||||
uses: aquasecurity/trivy-action@0.28.0
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
with:
|
||||
scan-type: "fs"
|
||||
scan-ref: "."
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -36,6 +36,9 @@ load-tests/results/*.json
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Redis dump (created when running redis locally without persistence config)
|
||||
*.rdb
|
||||
|
||||
# personal notes / Obsidian
|
||||
.obsidian/
|
||||
TEC/
|
||||
|
||||
97
AGENTS.md
Normal file
97
AGENTS.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# GoodGo Platform
|
||||
|
||||
Vietnamese real estate platform — monorepo powered by pnpm workspaces + Turborepo.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm db:generate # Generate Prisma client
|
||||
pnpm db:migrate:dev # Run migrations (needs PostgreSQL 16 + PostGIS)
|
||||
pnpm db:seed # Seed sample data (users, listings, districts)
|
||||
pnpm dev # Start all apps (API :3001, Web :3000)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- **apps/api** — NestJS backend (CQRS, DDD, clean architecture)
|
||||
- **apps/web** — Next.js 15 frontend (App Router, Tailwind, Zustand)
|
||||
- **libs/ai-services** — Python FastAPI AI/ML services (AVM, content moderation, NLP)
|
||||
- **libs/mcp-servers** — MCP tool server library (property search, analytics, valuation)
|
||||
- **prisma/** — Schema, migrations, seed scripts
|
||||
- **e2e/** — Playwright E2E tests (API + Web projects)
|
||||
|
||||
## Key Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `pnpm lint` | ESLint (auto-fixable with `--fix`) |
|
||||
| `pnpm typecheck` | TypeScript type checking |
|
||||
| `pnpm test` | Unit tests via Vitest (API only) |
|
||||
| `pnpm build` | Production build (all packages) |
|
||||
| `pnpm test:e2e` | Playwright E2E tests |
|
||||
| `pnpm db:studio` | Prisma Studio GUI |
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime**: Node.js >= 22, pnpm 10
|
||||
- **Backend**: NestJS, Prisma ORM, PostgreSQL 16 + PostGIS, Redis
|
||||
- **Frontend**: Next.js 15, React 18, Tailwind CSS 3, Zustand, Mapbox GL
|
||||
- **Testing**: Vitest (unit), Playwright (E2E)
|
||||
- **CI**: GitHub Actions (lint → typecheck → test → build)
|
||||
|
||||
## Project Structure (API)
|
||||
|
||||
```
|
||||
apps/api/src/modules/
|
||||
auth/ — Authentication (JWT, OAuth, refresh tokens, CSRF)
|
||||
listings/ — Property listings CRUD
|
||||
payments/ — VNPay, MoMo, ZaloPay payment integration
|
||||
subscriptions/ — Plans, quotas, usage tracking
|
||||
admin/ — Moderation, KYC, user management, audit logs
|
||||
analytics/ — Market data, heatmaps, price trends, AVM
|
||||
search/ — Geo search, full-text search (Typesense), saved searches
|
||||
notifications/ — Email, in-app notifications
|
||||
agents/ — Agent profiles, quality scores
|
||||
inquiries/ — Property inquiry management
|
||||
leads/ — Lead tracking and conversion
|
||||
reviews/ — Property reviews and ratings
|
||||
health/ — Liveness and readiness probes
|
||||
metrics/ — Prometheus metrics, web vitals
|
||||
mcp/ — MCP tool server endpoints
|
||||
shared/ — Domain primitives, guards, pipes, logging
|
||||
```
|
||||
|
||||
Each module follows DDD layers: `domain/` → `application/` → `infrastructure/` → `presentation/`.
|
||||
|
||||
## Project Structure (Libs)
|
||||
|
||||
```
|
||||
libs/
|
||||
ai-services/ — Python FastAPI AI/ML services (AVM, content moderation, NLP)
|
||||
mcp-servers/ — MCP tool server library (property search, analytics, valuation)
|
||||
```
|
||||
|
||||
## Database
|
||||
|
||||
- PostgreSQL 16 with PostGIS extension for geospatial queries
|
||||
- 22 models (User, Property, Listing, Payment, Subscription, etc.)
|
||||
- Migrations in `prisma/migrations/`
|
||||
- Seed data covers: users, agents, Ho Chi Minh City districts/wards, sample properties, subscription plans
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Required in `.env`:
|
||||
- `DATABASE_URL` — PostgreSQL connection string
|
||||
- `JWT_SECRET`, `JWT_REFRESH_SECRET` — Auth tokens
|
||||
- `VNPAY_*` — Payment gateway config
|
||||
- `MAPBOX_TOKEN` — Map rendering (frontend)
|
||||
- `REDIS_URL` — Cache layer (optional for dev)
|
||||
|
||||
## Conventions
|
||||
|
||||
- Import order enforced by eslint-plugin-import-x (external → internal → relative)
|
||||
- Path aliases: `@modules/*` in API, `@/*` in Web
|
||||
- Vietnamese UI text throughout (property types, districts, currency in VND)
|
||||
- All handlers return typed `Result<T>` or throw `DomainException`
|
||||
- Commit messages follow conventional commits
|
||||
37
CHANGELOG.md
37
CHANGELOG.md
@@ -7,6 +7,43 @@ và dự án này tuân theo [Semantic Versioning](https://semver.org/spec/v2.0.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### GOO-33 Documentation Sprint (2026-04-22)
|
||||
|
||||
#### Đã 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
|
||||
|
||||
### GOO-2 Lead Orchestrator Audit (2026-04-22)
|
||||
|
||||
#### 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
|
||||
|
||||
#### Đã sửa
|
||||
- GOO-3: Fix double CSRF middleware — login/register/payment callbacks hoạt động (Sprint 1) ✅
|
||||
|
||||
#### Đ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)
|
||||
|
||||
#### 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ị
|
||||
|
||||
317
CONTRIBUTING.md
317
CONTRIBUTING.md
@@ -1,5 +1,241 @@
|
||||
# Hướng Dẫn Đóng Góp
|
||||
|
||||
## Kỷ Luật Commit & Push (Bắt Buộc)
|
||||
|
||||
> Để 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)).
|
||||
|
||||
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.
|
||||
|
||||
### 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
|
||||
@@ -90,3 +326,84 @@ try {
|
||||
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! 🚀**
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ set -e
|
||||
|
||||
if [ "${RUN_MIGRATIONS}" = "true" ]; then
|
||||
echo "[entrypoint] Running Prisma migrations..."
|
||||
npx prisma migrate deploy --schema ./prisma/schema.prisma
|
||||
npx prisma migrate deploy --schema /app/prisma/schema.prisma
|
||||
echo "[entrypoint] Migrations complete."
|
||||
fi
|
||||
|
||||
|
||||
50
apps/api/docs/observability/README.md
Normal file
50
apps/api/docs/observability/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Observability — Read-Model / Projector (RFC-003 Phase 0)
|
||||
|
||||
Grafana dashboards and wiring notes for the read-model observability stack
|
||||
introduced in [GOO-192](/GOO/issues/GOO-192) under [GOO-94](/GOO/issues/GOO-94) §6 Phase 0.
|
||||
|
||||
## Metrics
|
||||
|
||||
All metrics live in the existing NestJS `metrics/` module
|
||||
(`apps/api/src/modules/metrics/`) and are scraped via the standard `/metrics`
|
||||
endpoint.
|
||||
|
||||
| Metric | Type | Labels | Purpose |
|
||||
| --------------------------------------- | --------- | --------- | --------------------------------------------------------- |
|
||||
| `read_model_projector_lag_seconds` | Gauge | `handler` | Seconds between latest source event and projector cursor. |
|
||||
| `read_model_refresh_duration_seconds` | Histogram | `view` | Duration of read-model / materialised view refreshes. |
|
||||
| `read_model_reconciliation_drift_total` | Counter | `model` | Count of drift discrepancies found during reconciliation. |
|
||||
|
||||
### Emit points
|
||||
|
||||
Inject `MetricsService` and call:
|
||||
|
||||
```ts
|
||||
metrics.setProjectorLag(handler, lagSeconds);
|
||||
metrics.recordReadModelRefresh(view, durationSeconds);
|
||||
metrics.recordReconciliationDrift(model, count?);
|
||||
```
|
||||
|
||||
## Dashboard
|
||||
|
||||
- File: `read-models-dashboard.json` (Grafana schema v38).
|
||||
- Import into Grafana (`Dashboards → Import → Upload JSON`), pick the Prometheus
|
||||
data source.
|
||||
- Variables: `handler`, `view`, `model` — derived from Prometheus label values.
|
||||
- Panels:
|
||||
1. Projector lag by handler (time series + thresholded)
|
||||
2. Max projector lag (stat, RAG 30s / 120s)
|
||||
3. Refresh duration p50/p95 by view
|
||||
4. Refresh throughput (refreshes/sec) by view
|
||||
5. Reconciliation drift rate by model (15m rate)
|
||||
6. Total drift events in last 24h (stat, RAG 1 / 10)
|
||||
|
||||
## Local verification
|
||||
|
||||
```bash
|
||||
pnpm --filter @goodgo/api dev
|
||||
curl -s http://localhost:3001/metrics | grep read_model_
|
||||
```
|
||||
|
||||
All three metric families should appear with `# HELP` / `# TYPE` headers even
|
||||
before any samples are recorded.
|
||||
77
apps/api/docs/observability/read-models-dashboard.json
Normal file
77
apps/api/docs/observability/read-models-dashboard.json
Normal file
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": "-- Grafana --",
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"graphTooltip": 1,
|
||||
"id": null,
|
||||
"uid": "goodgo-read-models",
|
||||
"title": "GoodGo · Read-Model Observability (RFC-003 Phase 0)",
|
||||
"tags": ["goodgo", "rfc-003", "read-models", "observability"],
|
||||
"timezone": "browser",
|
||||
"schemaVersion": 38,
|
||||
"version": 1,
|
||||
"refresh": "30s",
|
||||
"time": { "from": "now-6h", "to": "now" },
|
||||
"templating": {
|
||||
"list": [
|
||||
{ "name": "datasource", "type": "datasource", "query": "prometheus", "current": { "text": "Prometheus", "value": "Prometheus" } },
|
||||
{ "name": "handler", "type": "query", "datasource": "${datasource}", "query": "label_values(read_model_projector_lag_seconds, handler)", "includeAll": true, "multi": true, "refresh": 2 },
|
||||
{ "name": "view", "type": "query", "datasource": "${datasource}", "query": "label_values(read_model_refresh_duration_seconds_bucket, view)", "includeAll": true, "multi": true, "refresh": 2 },
|
||||
{ "name": "model", "type": "query", "datasource": "${datasource}", "query": "label_values(read_model_reconciliation_drift_total, model)", "includeAll": true, "multi": true, "refresh": 2 }
|
||||
]
|
||||
},
|
||||
"panels": [
|
||||
{
|
||||
"id": 1, "type": "timeseries", "title": "Projector lag (seconds) — by handler",
|
||||
"datasource": "${datasource}", "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
|
||||
"fieldConfig": { "defaults": { "unit": "s", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 30 }, { "color": "red", "value": 120 }] } } },
|
||||
"targets": [{ "expr": "read_model_projector_lag_seconds{handler=~\"$handler\"}", "legendFormat": "{{handler}}", "refId": "A" }]
|
||||
},
|
||||
{
|
||||
"id": 2, "type": "stat", "title": "Max projector lag (current)",
|
||||
"datasource": "${datasource}", "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
|
||||
"fieldConfig": { "defaults": { "unit": "s", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 30 }, { "color": "red", "value": 120 }] } } },
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } },
|
||||
"targets": [{ "expr": "max(read_model_projector_lag_seconds{handler=~\"$handler\"})", "refId": "A" }]
|
||||
},
|
||||
{
|
||||
"id": 3, "type": "timeseries", "title": "Refresh duration p50/p95 — by view",
|
||||
"datasource": "${datasource}", "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
|
||||
"fieldConfig": { "defaults": { "unit": "s" } },
|
||||
"targets": [
|
||||
{ "expr": "histogram_quantile(0.95, sum by (view, le) (rate(read_model_refresh_duration_seconds_bucket{view=~\"$view\"}[5m])))", "legendFormat": "p95 · {{view}}", "refId": "A" },
|
||||
{ "expr": "histogram_quantile(0.50, sum by (view, le) (rate(read_model_refresh_duration_seconds_bucket{view=~\"$view\"}[5m])))", "legendFormat": "p50 · {{view}}", "refId": "B" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4, "type": "timeseries", "title": "Refresh throughput (refreshes/sec) — by view",
|
||||
"datasource": "${datasource}", "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
|
||||
"fieldConfig": { "defaults": { "unit": "ops" } },
|
||||
"targets": [{ "expr": "sum by (view) (rate(read_model_refresh_duration_seconds_count{view=~\"$view\"}[5m]))", "legendFormat": "{{view}}", "refId": "A" }]
|
||||
},
|
||||
{
|
||||
"id": 5, "type": "timeseries", "title": "Reconciliation drift rate — by model",
|
||||
"datasource": "${datasource}", "gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
|
||||
"fieldConfig": { "defaults": { "unit": "ops" } },
|
||||
"targets": [{ "expr": "sum by (model) (rate(read_model_reconciliation_drift_total{model=~\"$model\"}[15m]))", "legendFormat": "{{model}}", "refId": "A" }]
|
||||
},
|
||||
{
|
||||
"id": 6, "type": "stat", "title": "Total drift events (last 24h)",
|
||||
"datasource": "${datasource}", "gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
|
||||
"fieldConfig": { "defaults": { "unit": "short", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 1 }, { "color": "red", "value": 10 }] } } },
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } },
|
||||
"targets": [{ "expr": "sum by (model) (increase(read_model_reconciliation_drift_total{model=~\"$model\"}[24h]))", "legendFormat": "{{model}}", "refId": "A" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -16,7 +16,11 @@
|
||||
"@anthropic-ai/sdk": "^0.89.0",
|
||||
"@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",
|
||||
@@ -49,6 +53,7 @@
|
||||
"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",
|
||||
@@ -75,6 +80,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -20,8 +20,11 @@ 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';
|
||||
@@ -29,6 +32,7 @@ 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';
|
||||
@@ -37,11 +41,11 @@ import { AppController } from './app.controller';
|
||||
imports: [
|
||||
SentryModule.forRoot(),
|
||||
BullModule.forRoot({
|
||||
connection: {
|
||||
host: process.env['REDIS_HOST'] ?? 'localhost',
|
||||
port: Number(process.env['REDIS_PORT'] ?? 6379),
|
||||
password: process.env['REDIS_PASSWORD'] ?? undefined,
|
||||
},
|
||||
// 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'),
|
||||
}),
|
||||
CqrsModule.forRoot(),
|
||||
ScheduleModule.forRoot(),
|
||||
@@ -56,11 +60,14 @@ import { AppController } from './app.controller';
|
||||
FavoritesModule,
|
||||
SearchModule,
|
||||
NotificationsModule,
|
||||
OsmSyncModule,
|
||||
PaymentsModule,
|
||||
PoiModule,
|
||||
SubscriptionsModule,
|
||||
AdminModule,
|
||||
AnalyticsModule,
|
||||
MetricsModule,
|
||||
MetricsModule.withQueueMetrics(),
|
||||
McpIntegrationModule,
|
||||
MessagingModule,
|
||||
ReportsModule,
|
||||
@@ -68,6 +75,9 @@ import { AppController } from './app.controller';
|
||||
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
|
||||
@@ -140,6 +150,10 @@ 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, @typescript-eslint/consistent-type-imports
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { nodeProfilingIntegration } = require('@sentry/profiling-node') as typeof import('@sentry/profiling-node');
|
||||
integrations.push(nodeProfilingIntegration());
|
||||
} catch {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { forwardRef, 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';
|
||||
@@ -21,6 +22,7 @@ import { UserDeactivatedListener } from './application/listeners/user-deactivate
|
||||
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';
|
||||
@@ -31,6 +33,7 @@ import { SystemSettingsService } from './application/services/system-settings.se
|
||||
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';
|
||||
@@ -54,6 +57,7 @@ const CommandHandlers = [
|
||||
|
||||
const QueryHandlers = [
|
||||
GetModerationQueueHandler,
|
||||
GetFlaggedListingsHandler,
|
||||
GetDashboardStatsHandler,
|
||||
GetRevenueStatsHandler,
|
||||
GetUsersHandler,
|
||||
@@ -65,7 +69,7 @@ const QueryHandlers = [
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [CqrsModule, AuthModule, ListingsModule, SubscriptionsModule],
|
||||
imports: [CqrsModule, AuthModule, forwardRef(() => ListingsModule), SubscriptionsModule],
|
||||
controllers: [
|
||||
AdminController,
|
||||
AdminModerationController,
|
||||
@@ -82,6 +86,7 @@ const QueryHandlers = [
|
||||
|
||||
// Services
|
||||
SystemSettingsService,
|
||||
{ provide: AI_CONFIG_PROVIDER, useClass: SystemSettingsAiConfigProvider },
|
||||
|
||||
// CQRS
|
||||
...CommandHandlers,
|
||||
@@ -93,6 +98,6 @@ const QueryHandlers = [
|
||||
AdminAuditListener,
|
||||
ModerationAuditListener,
|
||||
],
|
||||
exports: [SystemSettingsService],
|
||||
exports: [SystemSettingsService, AI_CONFIG_PROVIDER],
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
||||
@@ -2,8 +2,8 @@ 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 { USER_REPOSITORY, type IUserRepository } from '../../../../auth/domain/repositories/user.repository';
|
||||
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';
|
||||
|
||||
@@ -2,8 +2,8 @@ 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 { USER_REPOSITORY, type IUserRepository } from '../../../../auth/domain/repositories/user.repository';
|
||||
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';
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class GetFlaggedListingsQuery {
|
||||
constructor(
|
||||
public readonly page: number = 1,
|
||||
public readonly limit: number = 20,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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,3 +1,4 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import {
|
||||
type DashboardStats,
|
||||
@@ -43,67 +44,76 @@ 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 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;
|
||||
}
|
||||
const cacheKey = buildCacheKey(startDate, endDate, groupBy);
|
||||
const cached = revenueStatsCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
return Array.from(grouped.entries()).map(([period, stats]) => ({
|
||||
period,
|
||||
...stats,
|
||||
// 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),
|
||||
}));
|
||||
|
||||
revenueStatsCache.set(cacheKey, { expiresAt: Date.now() + 60_000, data });
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ import { RejectKycCommand } from '../../application/commands/reject-kyc/reject-k
|
||||
import { type RejectKycResult } from '../../application/commands/reject-kyc/reject-kyc.handler';
|
||||
import { 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 {
|
||||
@@ -139,6 +141,27 @@ export class AdminModerationController {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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')
|
||||
|
||||
@@ -22,8 +22,8 @@ import { type ProvisionParkOperatorResult } from '../../application/commands/pro
|
||||
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 { GetAiSettingsQuery } from '../../application/queries/get-ai-settings/get-ai-settings.query';
|
||||
import { type AiSettingsDto } from '../../application/queries/get-ai-settings/get-ai-settings.handler';
|
||||
import { 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';
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsDateString, IsIn, IsOptional } from 'class-validator';
|
||||
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],
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export class RevenueStatsDto {
|
||||
@ApiProperty({ description: 'Start date (ISO 8601)', example: '2025-01-01' })
|
||||
@@ -8,6 +34,7 @@ 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' })
|
||||
|
||||
98
apps/api/src/modules/analytics/README.md
Normal file
98
apps/api/src/modules/analytics/README.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# 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,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { makeCounterProvider, makeHistogramProvider } from '@willsoto/nestjs-prometheus';
|
||||
import { AdminModule } from '@modules/admin';
|
||||
import { ListingsModule } from '@modules/listings';
|
||||
import { ProjectsModule } from '@modules/projects';
|
||||
@@ -8,16 +9,21 @@ import { TrackEventHandler } from './application/commands/track-event/track-even
|
||||
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 { GetProjectAiAdviceHandler } from './application/queries/get-project-ai-advice/get-project-ai-advice.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';
|
||||
@@ -36,6 +42,12 @@ import {
|
||||
PrismaNeighborhoodScoreService,
|
||||
} 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';
|
||||
|
||||
@@ -47,7 +59,9 @@ const CommandHandlers = [
|
||||
|
||||
const QueryHandlers = [
|
||||
GetMarketReportHandler,
|
||||
GetMarketHistoryHandler,
|
||||
GetHeatmapHandler,
|
||||
GetListingVolumeWardHandler,
|
||||
GetPriceTrendHandler,
|
||||
GetDistrictStatsHandler,
|
||||
GetValuationHandler,
|
||||
@@ -61,6 +75,9 @@ const QueryHandlers = [
|
||||
IndustrialValuationHandler,
|
||||
GetListingAiAdviceHandler,
|
||||
GetProjectAiAdviceHandler,
|
||||
GetMarketSnapshotHandler,
|
||||
GetPriceMoversHandler,
|
||||
GetTrendingAreasHandler,
|
||||
];
|
||||
|
||||
const EventHandlers = [
|
||||
@@ -68,7 +85,12 @@ const EventHandlers = [
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [CqrsModule, ListingsModule, AdminModule, ProjectsModule],
|
||||
imports: [
|
||||
CqrsModule,
|
||||
forwardRef(() => ListingsModule),
|
||||
ProjectsModule,
|
||||
forwardRef(() => AdminModule), // for AI_CONFIG_PROVIDER (used by AI advice handlers)
|
||||
],
|
||||
controllers: [AnalyticsController, AvmController],
|
||||
providers: [
|
||||
// AI service client
|
||||
@@ -88,6 +110,25 @@ const EventHandlers = [
|
||||
|
||||
// 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 { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||
import { DomainException } from '@modules/shared';
|
||||
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||
import {
|
||||
type IAVMService,
|
||||
type BatchValuationResult,
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
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,11 +15,13 @@ 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);
|
||||
handler = new GetHeatmapHandler(mockRepo as any, mockCache, { log: vi.fn(), error: vi.fn(), warn: vi.fn() } as any);
|
||||
});
|
||||
|
||||
it('returns heatmap data for a city and period', async () => {
|
||||
@@ -34,6 +36,7 @@ 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');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
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,3 +1,4 @@
|
||||
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';
|
||||
@@ -19,13 +20,21 @@ 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(),
|
||||
};
|
||||
handler = new GetNeighborhoodScoreHandler(mockService as any);
|
||||
// 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,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns cached score when available', async () => {
|
||||
@@ -48,4 +57,17 @@ 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
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,6 +1,5 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { type CacheService, type PrismaService } from '@modules/shared';
|
||||
import { DomainException } from '@modules/shared';
|
||||
import { type CacheService, type PrismaService, 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 { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||
import { DomainException, NotFoundException } from '@modules/shared';
|
||||
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||
import { ValuationEntity } from '../../domain/entities/valuation.entity';
|
||||
import { type IValuationRepository } from '../../domain/repositories/valuation.repository';
|
||||
import { ValuationExplanationHandler } from '../queries/valuation-explanation/valuation-explanation.handler';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||
import { DomainException } from '@modules/shared';
|
||||
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||
import { ValuationEntity } from '../../domain/entities/valuation.entity';
|
||||
import { type IValuationRepository } from '../../domain/repositories/valuation.repository';
|
||||
import { ValuationHistoryHandler } from '../queries/valuation-history/valuation-history.handler';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { EventsHandler, type IEventHandler, CommandBus } from '@nestjs/cqrs';
|
||||
import { ListingCreatedEvent, ModerateListingCommand } from '@modules/listings';
|
||||
import { ModerateListingCommand } from '@modules/listings';
|
||||
import { ListingCreatedEvent } from '@modules/listings/domain/events/listing-created.event';
|
||||
import { PrismaService, LoggerService } from '@modules/shared';
|
||||
import {
|
||||
AI_SERVICE_CLIENT,
|
||||
|
||||
@@ -5,13 +5,15 @@ import {
|
||||
MARKET_INDEX_REPOSITORY,
|
||||
type IMarketIndexRepository,
|
||||
type HeatmapDataPoint,
|
||||
type WardHeatmapDataPoint,
|
||||
} from '../../../domain/repositories/market-index.repository';
|
||||
import { GetHeatmapQuery } from './get-heatmap.query';
|
||||
|
||||
export interface HeatmapDto {
|
||||
city: string;
|
||||
period: string;
|
||||
dataPoints: HeatmapDataPoint[];
|
||||
level: 'district' | 'ward';
|
||||
dataPoints: HeatmapDataPoint[] | WardHeatmapDataPoint[];
|
||||
}
|
||||
|
||||
@QueryHandler(GetHeatmapQuery)
|
||||
@@ -24,15 +26,31 @@ export class GetHeatmapHandler implements IQueryHandler<GetHeatmapQuery> {
|
||||
|
||||
async execute(query: GetHeatmapQuery): Promise<HeatmapDto> {
|
||||
try {
|
||||
const cacheKey = CacheService.buildKey(CachePrefix.MARKET_HEATMAP, query.city, query.period);
|
||||
const cacheKey = CacheService.buildKey(
|
||||
CachePrefix.MARKET_HEATMAP,
|
||||
query.city,
|
||||
query.period,
|
||||
query.level,
|
||||
query.district ?? 'all',
|
||||
);
|
||||
|
||||
const ttl = query.level === 'ward' ? CacheTTL.HEATMAP_WARD : CacheTTL.HEATMAP;
|
||||
|
||||
return this.cache.getOrSet(
|
||||
cacheKey,
|
||||
async () => {
|
||||
if (query.level === 'ward') {
|
||||
const dataPoints = await this.marketIndexRepo.getHeatmapWard(
|
||||
query.city,
|
||||
query.period,
|
||||
query.district,
|
||||
);
|
||||
return { city: query.city, period: query.period, level: 'ward' as const, dataPoints };
|
||||
}
|
||||
const dataPoints = await this.marketIndexRepo.getHeatmap(query.city, query.period);
|
||||
return { city: query.city, period: query.period, dataPoints };
|
||||
return { city: query.city, period: query.period, level: 'district' as const, dataPoints };
|
||||
},
|
||||
CacheTTL.HEATMAP,
|
||||
ttl,
|
||||
'heatmap',
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
export type HeatmapLevel = 'district' | 'ward';
|
||||
|
||||
export class GetHeatmapQuery {
|
||||
constructor(
|
||||
public readonly city: string,
|
||||
public readonly period: string,
|
||||
public readonly level: HeatmapLevel = 'district',
|
||||
public readonly district?: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { HttpStatus, Inject } from '@nestjs/common';
|
||||
import { QueryBus, QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, ErrorCode, LoggerService } from '@modules/shared';
|
||||
import { SystemSettingsService } from '@modules/admin';
|
||||
// Direct internal path: barrel `@modules/listings` exports `ListingsModule`
|
||||
// first, which transitively imports the analytics handler back here. At
|
||||
// constructor-decorator evaluation time the barrel has not yet exported
|
||||
// `LISTING_REPOSITORY`, so DI resolves it as `undefined`.
|
||||
// eslint-disable-next-line no-restricted-imports -- circular-import workaround; see comment above
|
||||
import {
|
||||
LISTING_REPOSITORY,
|
||||
type IListingRepository,
|
||||
} from '@modules/listings';
|
||||
import { type ListingDetailData } from '../../../../listings/domain/repositories/listing-read.dto';
|
||||
} from '@modules/listings/domain/repositories/listing.repository';
|
||||
import {
|
||||
type NearbyPOIDto,
|
||||
type NearbyPOIsResultDto,
|
||||
} from '../get-nearby-pois/get-nearby-pois.handler';
|
||||
import { GetNearbyPOIsQuery } from '../get-nearby-pois/get-nearby-pois.query';
|
||||
AI_CONFIG_PROVIDER,
|
||||
DomainException,
|
||||
ErrorCode,
|
||||
type IAIConfigProvider,
|
||||
LoggerService,
|
||||
} from '@modules/shared';
|
||||
import { type ListingDetailData } from '../../../../listings/domain/repositories/listing-read.dto';
|
||||
import { type NeighborhoodScoreResult } from '../../../domain/services/neighborhood-score.service';
|
||||
import { GetNeighborhoodScoreQuery } from '../get-neighborhood-score/get-neighborhood-score.query';
|
||||
import {
|
||||
asInt,
|
||||
asString,
|
||||
@@ -23,6 +27,12 @@ import {
|
||||
jsonShapeError,
|
||||
parseJsonObject,
|
||||
} from '../_shared/ai-json-client';
|
||||
import {
|
||||
type NearbyPOIDto,
|
||||
type NearbyPOIsResultDto,
|
||||
} from '../get-nearby-pois/get-nearby-pois.handler';
|
||||
import { GetNearbyPOIsQuery } from '../get-nearby-pois/get-nearby-pois.query';
|
||||
import { GetNeighborhoodScoreQuery } from '../get-neighborhood-score/get-neighborhood-score.query';
|
||||
import { GetListingAiAdviceQuery } from './get-listing-ai-advice.query';
|
||||
|
||||
/** Shape returned by Anthropic (parsed from first content block). */
|
||||
@@ -91,7 +101,8 @@ export class GetListingAiAdviceHandler
|
||||
@Inject(LISTING_REPOSITORY)
|
||||
private readonly listingRepo: IListingRepository,
|
||||
private readonly queryBus: QueryBus,
|
||||
private readonly systemSettings: SystemSettingsService,
|
||||
@Inject(AI_CONFIG_PROVIDER)
|
||||
private readonly aiConfig: IAIConfigProvider,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@@ -113,7 +124,7 @@ export class GetListingAiAdviceHandler
|
||||
this.fetchScore(listing),
|
||||
]);
|
||||
|
||||
const settings = await this.systemSettings.getAiSettings();
|
||||
const settings = await this.aiConfig.getAiConfig();
|
||||
if (!settings.apiKey) {
|
||||
throw new DomainException(
|
||||
ErrorCode.AI_NOT_CONFIGURED,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Inject, NotFoundException, InternalServerErrorException } from '@nestjs/common';
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, CacheService, CachePrefix, CacheTTL, LoggerService } from '@modules/shared';
|
||||
import {
|
||||
MARKET_INDEX_REPOSITORY,
|
||||
type IMarketIndexRepository,
|
||||
type ListingVolumeWardResult,
|
||||
} from '../../../domain/repositories/market-index.repository';
|
||||
import { GetListingVolumeWardQuery } from './get-listing-volume-ward.query';
|
||||
|
||||
export type ListingVolumeWardDto = ListingVolumeWardResult;
|
||||
|
||||
@QueryHandler(GetListingVolumeWardQuery)
|
||||
export class GetListingVolumeWardHandler implements IQueryHandler<GetListingVolumeWardQuery> {
|
||||
constructor(
|
||||
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
|
||||
private readonly cache: CacheService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(query: GetListingVolumeWardQuery): Promise<ListingVolumeWardDto> {
|
||||
try {
|
||||
const cacheKey = CacheService.buildKey(
|
||||
CachePrefix.MARKET_HEATMAP,
|
||||
'ward-volume',
|
||||
query.wardId,
|
||||
query.period,
|
||||
);
|
||||
|
||||
const result = await this.cache.getOrSet(
|
||||
cacheKey,
|
||||
async () => this.marketIndexRepo.getListingVolumeByWard(query.wardId, query.period),
|
||||
CacheTTL.HEATMAP_WARD,
|
||||
'listing-volume-ward',
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw new NotFoundException(
|
||||
`Không tìm thấy dữ liệu khối lượng tin đăng cho phường "${query.wardId}" trong kỳ "${query.period}".`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException || error instanceof NotFoundException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to truy vấn khối lượng tin đăng theo phường: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException(
|
||||
'Không thể truy vấn dữ liệu khối lượng tin đăng theo phường. Vui lòng thử lại sau.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class GetListingVolumeWardQuery {
|
||||
constructor(
|
||||
public readonly wardId: string,
|
||||
public readonly period: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { type IMarketIndexRepository } from '../../../../domain/repositories/market-index.repository';
|
||||
import { GetMarketHistoryHandler } from '../get-market-history.handler';
|
||||
import { GetMarketHistoryQuery } from '../get-market-history.query';
|
||||
|
||||
describe('GetMarketHistoryHandler', () => {
|
||||
let handler: GetMarketHistoryHandler;
|
||||
let mockRepo: { getMarketHistory: ReturnType<typeof vi.fn> };
|
||||
let mockCache: { getOrSet: ReturnType<typeof vi.fn> };
|
||||
let mockLogger: { error: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = { getMarketHistory: vi.fn() };
|
||||
mockCache = {
|
||||
getOrSet: vi.fn((_key: string, fn: () => Promise<unknown>) => fn()),
|
||||
};
|
||||
mockLogger = { error: vi.fn() };
|
||||
|
||||
handler = new GetMarketHistoryHandler(
|
||||
mockRepo as unknown as IMarketIndexRepository,
|
||||
mockCache as any,
|
||||
mockLogger as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return market history points for 12m monthly', async () => {
|
||||
const points = [
|
||||
{ date: '2025-05', avgPrice: 50000000, medianPrice: '45000000', listingsCount: 120, inquiriesCount: 0, daysOnMarket: 35 },
|
||||
{ date: '2025-06', avgPrice: 51000000, medianPrice: '46000000', listingsCount: 130, inquiriesCount: 0, daysOnMarket: 33 },
|
||||
];
|
||||
mockRepo.getMarketHistory.mockResolvedValue(points);
|
||||
|
||||
const query = new GetMarketHistoryQuery('HCMC', '12m', 'monthly');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.city).toBe('HCMC');
|
||||
expect(result.points).toEqual(points);
|
||||
expect(mockRepo.getMarketHistory).toHaveBeenCalledWith('HCMC', expect.any(Array));
|
||||
// Should generate 12 monthly periods
|
||||
const calledPeriods = mockRepo.getMarketHistory.mock.calls[0][1] as string[];
|
||||
expect(calledPeriods).toHaveLength(12);
|
||||
});
|
||||
|
||||
it('should return market history for 6m period', async () => {
|
||||
mockRepo.getMarketHistory.mockResolvedValue([]);
|
||||
|
||||
const query = new GetMarketHistoryQuery('HCMC', '6m', 'monthly');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.city).toBe('HCMC');
|
||||
expect(result.points).toEqual([]);
|
||||
const calledPeriods = mockRepo.getMarketHistory.mock.calls[0][1] as string[];
|
||||
expect(calledPeriods).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('should use cache with 6h TTL', async () => {
|
||||
mockRepo.getMarketHistory.mockResolvedValue([]);
|
||||
const query = new GetMarketHistoryQuery('HCMC', '12m', 'monthly');
|
||||
|
||||
await handler.execute(query);
|
||||
|
||||
expect(mockCache.getOrSet).toHaveBeenCalledWith(
|
||||
expect.stringContaining('market_history'),
|
||||
expect.any(Function),
|
||||
21600,
|
||||
'market_history',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw InternalServerErrorException on unexpected errors', async () => {
|
||||
mockRepo.getMarketHistory.mockRejectedValue(new Error('DB connection lost'));
|
||||
|
||||
const query = new GetMarketHistoryQuery('HCMC', '12m', 'monthly');
|
||||
|
||||
await expect(handler.execute(query)).rejects.toThrow(InternalServerErrorException);
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, CacheService, CachePrefix, CacheTTL, LoggerService } from '@modules/shared';
|
||||
import {
|
||||
MARKET_INDEX_REPOSITORY,
|
||||
type IMarketIndexRepository,
|
||||
type MarketHistoryPoint,
|
||||
} from '../../../domain/repositories/market-index.repository';
|
||||
import { GetMarketHistoryQuery } from './get-market-history.query';
|
||||
|
||||
export interface MarketHistoryDto {
|
||||
city: string;
|
||||
points: MarketHistoryPoint[];
|
||||
}
|
||||
|
||||
@QueryHandler(GetMarketHistoryQuery)
|
||||
export class GetMarketHistoryHandler implements IQueryHandler<GetMarketHistoryQuery> {
|
||||
constructor(
|
||||
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
|
||||
private readonly cache: CacheService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(query: GetMarketHistoryQuery): Promise<MarketHistoryDto> {
|
||||
try {
|
||||
const cacheKey = CacheService.buildKey(
|
||||
CachePrefix.MARKET_HISTORY,
|
||||
query.city,
|
||||
query.period,
|
||||
query.granularity,
|
||||
query.propertyType ?? 'all',
|
||||
);
|
||||
|
||||
return await this.cache.getOrSet(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const periods = this.generatePeriods(query.period, query.granularity);
|
||||
const points = await this.marketIndexRepo.getMarketHistory(query.city, periods);
|
||||
return { city: query.city, points };
|
||||
},
|
||||
CacheTTL.MARKET_HISTORY,
|
||||
'market_history',
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to get market history: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException(
|
||||
'Không thể truy vấn lịch sử thị trường. Vui lòng thử lại sau.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate period strings based on the requested look-back and granularity.
|
||||
* E.g. "12m" with "monthly" → ["2025-05", "2025-06", ..., "2026-04"]
|
||||
*/
|
||||
private generatePeriods(period: string, granularity: 'monthly' | 'weekly'): string[] {
|
||||
const match = period.match(/^(\d+)m$/);
|
||||
const months = match?.[1] ? parseInt(match[1], 10) : 12;
|
||||
|
||||
const now = new Date();
|
||||
const periods: string[] = [];
|
||||
|
||||
if (granularity === 'monthly') {
|
||||
for (let i = months - 1; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
periods.push(`${yyyy}-${mm}`);
|
||||
}
|
||||
} else {
|
||||
// weekly: generate ISO week strings for the past N months
|
||||
const startDate = new Date(now.getFullYear(), now.getMonth() - months, now.getDate());
|
||||
const cursor = new Date(startDate);
|
||||
while (cursor <= now) {
|
||||
const yyyy = cursor.getFullYear();
|
||||
const week = this.getISOWeek(cursor);
|
||||
periods.push(`${yyyy}-W${String(week).padStart(2, '0')}`);
|
||||
cursor.setDate(cursor.getDate() + 7);
|
||||
}
|
||||
}
|
||||
|
||||
return periods;
|
||||
}
|
||||
|
||||
private getISOWeek(date: Date): number {
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||
const dayNum = d.getUTCDay() || 7;
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export class GetMarketHistoryQuery {
|
||||
constructor(
|
||||
public readonly city: string,
|
||||
public readonly period: string,
|
||||
public readonly granularity: 'monthly' | 'weekly',
|
||||
public readonly propertyType?: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { type PropertyType, ListingStatus, Prisma } from '@prisma/client';
|
||||
import { DomainException, CacheService, CachePrefix, CacheTTL, LoggerService, PrismaService } from '@modules/shared';
|
||||
import { GetMarketSnapshotQuery } from './get-market-snapshot.query';
|
||||
|
||||
export interface PriceChangePct {
|
||||
d1: number;
|
||||
d7: number;
|
||||
d30: number;
|
||||
}
|
||||
|
||||
export interface MarketSnapshotDto {
|
||||
city: string;
|
||||
propertyType?: PropertyType;
|
||||
activeCount: number;
|
||||
avgPrice: number;
|
||||
medianPrice: number;
|
||||
priceChangePct: PriceChangePct;
|
||||
avgPricePerM2: number;
|
||||
daysOnMarket: number;
|
||||
newListings24h: number;
|
||||
cachedAt: string | null;
|
||||
nextRefreshAt: string | null;
|
||||
}
|
||||
|
||||
@QueryHandler(GetMarketSnapshotQuery)
|
||||
export class GetMarketSnapshotHandler implements IQueryHandler<GetMarketSnapshotQuery> {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly cache: CacheService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(query: GetMarketSnapshotQuery): Promise<MarketSnapshotDto> {
|
||||
try {
|
||||
const cacheKey = CacheService.buildKey(
|
||||
CachePrefix.MARKET_SNAPSHOT,
|
||||
query.city,
|
||||
query.propertyType,
|
||||
);
|
||||
|
||||
return await this.cache.getOrSet(
|
||||
cacheKey,
|
||||
() => this.computeSnapshot(query.city, query.propertyType),
|
||||
CacheTTL.MARKET_SNAPSHOT,
|
||||
'market_snapshot',
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to get market snapshot: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException(
|
||||
'Không thể truy vấn tổng quan thị trường. Vui lòng thử lại sau.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async computeSnapshot(
|
||||
city: string,
|
||||
propertyType?: PropertyType,
|
||||
): Promise<MarketSnapshotDto> {
|
||||
const now = new Date();
|
||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const propertyWhere: Prisma.PropertyWhereInput = {
|
||||
city: { equals: city, mode: 'insensitive' },
|
||||
...(propertyType ? { propertyType } : {}),
|
||||
};
|
||||
|
||||
const baseListingWhere: Prisma.ListingWhereInput = {
|
||||
status: ListingStatus.ACTIVE,
|
||||
property: propertyWhere,
|
||||
};
|
||||
|
||||
// Run queries in parallel for performance
|
||||
const [
|
||||
activeAgg,
|
||||
medianResult,
|
||||
newListings24h,
|
||||
avgDaysOnMarket,
|
||||
priceChange1d,
|
||||
priceChange7d,
|
||||
priceChange30d,
|
||||
] = await Promise.all([
|
||||
// Active listings count + avg price + avg price/m2
|
||||
this.prisma.listing.aggregate({
|
||||
where: baseListingWhere,
|
||||
_count: true,
|
||||
_avg: {
|
||||
priceVND: true,
|
||||
pricePerM2: true,
|
||||
},
|
||||
}),
|
||||
|
||||
// Median price via raw SQL for efficiency
|
||||
this.prisma.$queryRaw<{ median: bigint | null }[]>`
|
||||
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l."priceVND")::bigint AS median
|
||||
FROM "Listing" l
|
||||
JOIN "Property" p ON p.id = l."propertyId"
|
||||
WHERE l.status = 'ACTIVE'
|
||||
AND LOWER(p.city) = LOWER(${city})
|
||||
${propertyType ? Prisma.sql`AND p."propertyType" = ${propertyType}::"PropertyType"` : Prisma.empty}
|
||||
`,
|
||||
|
||||
// New listings in last 24h
|
||||
this.prisma.listing.count({
|
||||
where: {
|
||||
...baseListingWhere,
|
||||
publishedAt: { gte: oneDayAgo },
|
||||
},
|
||||
}),
|
||||
|
||||
// Average days on market
|
||||
this.prisma.$queryRaw<{ avg_days: number | null }[]>`
|
||||
SELECT AVG(EXTRACT(EPOCH FROM (NOW() - l."publishedAt")) / 86400)::float AS avg_days
|
||||
FROM "Listing" l
|
||||
JOIN "Property" p ON p.id = l."propertyId"
|
||||
WHERE l.status = 'ACTIVE'
|
||||
AND l."publishedAt" IS NOT NULL
|
||||
AND LOWER(p.city) = LOWER(${city})
|
||||
${propertyType ? Prisma.sql`AND p."propertyType" = ${propertyType}::"PropertyType"` : Prisma.empty}
|
||||
`,
|
||||
|
||||
// Price change %: compare current avg vs avg of listings from 1d/7d/30d ago
|
||||
this.computePriceChangePct(city, propertyType, oneDayAgo, now),
|
||||
this.computePriceChangePct(city, propertyType, sevenDaysAgo, oneDayAgo),
|
||||
this.computePriceChangePct(city, propertyType, thirtyDaysAgo, sevenDaysAgo),
|
||||
]);
|
||||
|
||||
const currentAvg = Number(activeAgg._avg.priceVND ?? 0);
|
||||
const median = medianResult[0]?.median ? Number(medianResult[0].median) : 0;
|
||||
const avgPricePerM2 = activeAgg._avg.pricePerM2 ?? 0;
|
||||
const daysOnMarket = Math.round(avgDaysOnMarket[0]?.avg_days ?? 0);
|
||||
|
||||
return {
|
||||
city,
|
||||
propertyType,
|
||||
activeCount: activeAgg._count,
|
||||
avgPrice: currentAvg,
|
||||
medianPrice: median,
|
||||
priceChangePct: {
|
||||
d1: this.calcChangePct(currentAvg, priceChange1d),
|
||||
d7: this.calcChangePct(currentAvg, priceChange7d),
|
||||
d30: this.calcChangePct(currentAvg, priceChange30d),
|
||||
},
|
||||
avgPricePerM2: Math.round(avgPricePerM2),
|
||||
daysOnMarket,
|
||||
newListings24h,
|
||||
cachedAt: null, // Filled by CacheMetaInterceptor
|
||||
nextRefreshAt: null, // Filled by CacheMetaInterceptor
|
||||
};
|
||||
}
|
||||
|
||||
private async computePriceChangePct(
|
||||
city: string,
|
||||
propertyType: PropertyType | undefined,
|
||||
from: Date,
|
||||
to: Date,
|
||||
): Promise<number> {
|
||||
const result = await this.prisma.$queryRaw<{ avg_price: number | null }[]>`
|
||||
SELECT AVG(l."priceVND")::float AS avg_price
|
||||
FROM "Listing" l
|
||||
JOIN "Property" p ON p.id = l."propertyId"
|
||||
WHERE l.status = 'ACTIVE'
|
||||
AND l."publishedAt" >= ${from}
|
||||
AND l."publishedAt" < ${to}
|
||||
AND LOWER(p.city) = LOWER(${city})
|
||||
${propertyType ? Prisma.sql`AND p."propertyType" = ${propertyType}::"PropertyType"` : Prisma.empty}
|
||||
`;
|
||||
return result[0]?.avg_price ?? 0;
|
||||
}
|
||||
|
||||
private calcChangePct(current: number, previous: number): number {
|
||||
if (!previous || previous === 0) return 0;
|
||||
return Math.round(((current - previous) / previous) * 1000) / 10; // 1 decimal
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { type PropertyType } from '@prisma/client';
|
||||
|
||||
export class GetMarketSnapshotQuery {
|
||||
constructor(
|
||||
public readonly city: string,
|
||||
public readonly propertyType?: PropertyType,
|
||||
) {}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared';
|
||||
import {
|
||||
NEIGHBORHOOD_SCORE_SERVICE,
|
||||
type INeighborhoodScoreService,
|
||||
@@ -12,13 +13,27 @@ export class GetNeighborhoodScoreHandler implements IQueryHandler<GetNeighborhoo
|
||||
constructor(
|
||||
@Inject(NEIGHBORHOOD_SCORE_SERVICE)
|
||||
private readonly scoreService: INeighborhoodScoreService,
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
async execute(query: GetNeighborhoodScoreQuery): Promise<NeighborhoodScoreResult> {
|
||||
// Return cached score if available, otherwise calculate
|
||||
const existing = await this.scoreService.getScore(query.district, query.city);
|
||||
if (existing) return existing;
|
||||
const cacheKey = CacheService.buildKey(
|
||||
CachePrefix.NEIGHBORHOOD_SCORE,
|
||||
query.district,
|
||||
query.city,
|
||||
);
|
||||
|
||||
return this.scoreService.calculateAndSave(query.district, query.city);
|
||||
return this.cache.getOrSet(
|
||||
cacheKey,
|
||||
async () => {
|
||||
// Return cached DB score if available, otherwise calculate
|
||||
const existing = await this.scoreService.getScore(query.district, query.city);
|
||||
if (existing) return existing;
|
||||
|
||||
return this.scoreService.calculateAndSave(query.district, query.city);
|
||||
},
|
||||
CacheTTL.NEIGHBORHOOD_SCORE,
|
||||
'neighborhood-score',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, CacheService, CachePrefix, CacheTTL, Cacheable, LoggerService, PrismaService } from '@modules/shared';
|
||||
import { GetPriceMoversQuery } from './get-price-movers.query';
|
||||
|
||||
export interface PriceMoverItem {
|
||||
districtId: string;
|
||||
name: string;
|
||||
currentAvgPrice: number;
|
||||
previousAvgPrice: number;
|
||||
changePct: number;
|
||||
sampleSize: number;
|
||||
}
|
||||
|
||||
export interface PriceMoversDto {
|
||||
direction: 'up' | 'down';
|
||||
period: string;
|
||||
level: string;
|
||||
limit: number;
|
||||
movers: PriceMoverItem[];
|
||||
}
|
||||
|
||||
/** Days extracted from period string, e.g. '7d' → 7 */
|
||||
function periodToDays(period: string): number {
|
||||
return parseInt(period.replace('d', ''), 10);
|
||||
}
|
||||
|
||||
interface RawPriceMoverRow {
|
||||
district: string;
|
||||
current_avg: number | null;
|
||||
previous_avg: number | null;
|
||||
sample_size: bigint;
|
||||
}
|
||||
|
||||
@QueryHandler(GetPriceMoversQuery)
|
||||
export class GetPriceMoversHandler implements IQueryHandler<GetPriceMoversQuery> {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@Cacheable({
|
||||
prefix: CachePrefix.PRICE_MOVERS,
|
||||
ttl: CacheTTL.PRICE_MOVERS,
|
||||
resource: 'price_movers',
|
||||
keyFrom: (query: unknown) => {
|
||||
const q = query as GetPriceMoversQuery;
|
||||
return [q.direction, q.period, String(q.limit), q.level];
|
||||
},
|
||||
})
|
||||
async execute(query: GetPriceMoversQuery): Promise<PriceMoversDto> {
|
||||
const { direction, period, limit, level } = query;
|
||||
|
||||
try {
|
||||
const days = periodToDays(period);
|
||||
const now = new Date();
|
||||
const currentStart = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
|
||||
const previousStart = new Date(currentStart.getTime() - days * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Compare average listing price per district between current window and previous window.
|
||||
// Only include districts with at least 10 listings in the current window (min sample size).
|
||||
const rows = await this.prisma.$queryRaw<RawPriceMoverRow[]>`
|
||||
WITH current_window AS (
|
||||
SELECT
|
||||
p.district,
|
||||
AVG(l."priceVND") AS avg_price,
|
||||
COUNT(l.id) AS sample_size
|
||||
FROM "Listing" l
|
||||
INNER JOIN "Property" p ON p.id = l."propertyId"
|
||||
WHERE l."createdAt" >= ${currentStart}
|
||||
AND l.status = 'ACTIVE'
|
||||
AND l."priceVND" > 0
|
||||
GROUP BY p.district
|
||||
HAVING COUNT(l.id) >= 10
|
||||
),
|
||||
previous_window AS (
|
||||
SELECT
|
||||
p.district,
|
||||
AVG(l."priceVND") AS avg_price
|
||||
FROM "Listing" l
|
||||
INNER JOIN "Property" p ON p.id = l."propertyId"
|
||||
WHERE l."createdAt" >= ${previousStart}
|
||||
AND l."createdAt" < ${currentStart}
|
||||
AND l.status = 'ACTIVE'
|
||||
AND l."priceVND" > 0
|
||||
GROUP BY p.district
|
||||
)
|
||||
SELECT
|
||||
c.district,
|
||||
c.avg_price AS current_avg,
|
||||
pr.avg_price AS previous_avg,
|
||||
c.sample_size
|
||||
FROM current_window c
|
||||
INNER JOIN previous_window pr ON pr.district = c.district
|
||||
WHERE pr.avg_price > 0
|
||||
`;
|
||||
|
||||
// Compute changePct and sort by direction
|
||||
const computed = rows
|
||||
.map((r) => {
|
||||
const currentAvg = Number(r.current_avg);
|
||||
const previousAvg = Number(r.previous_avg);
|
||||
const changePct = ((currentAvg - previousAvg) / previousAvg) * 100;
|
||||
return {
|
||||
district: r.district,
|
||||
currentAvgPrice: Math.round(currentAvg),
|
||||
previousAvgPrice: Math.round(previousAvg),
|
||||
changePct: Math.round(changePct * 100) / 100,
|
||||
sampleSize: Number(r.sample_size),
|
||||
};
|
||||
})
|
||||
.filter((r) => (direction === 'up' ? r.changePct > 0 : r.changePct < 0));
|
||||
|
||||
// Sort: 'up' → descending changePct, 'down' → ascending changePct
|
||||
computed.sort((a, b) =>
|
||||
direction === 'up' ? b.changePct - a.changePct : a.changePct - b.changePct,
|
||||
);
|
||||
|
||||
const top = computed.slice(0, limit);
|
||||
|
||||
const movers: PriceMoverItem[] = top.map((r) => ({
|
||||
districtId: r.district,
|
||||
name: r.district,
|
||||
currentAvgPrice: r.currentAvgPrice,
|
||||
previousAvgPrice: r.previousAvgPrice,
|
||||
changePct: r.changePct,
|
||||
sampleSize: r.sampleSize,
|
||||
}));
|
||||
|
||||
return { direction, period, level, limit, movers };
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to query price movers: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException(
|
||||
'Không thể truy vấn biến động giá. Vui lòng thử lại sau.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export class GetPriceMoversQuery {
|
||||
constructor(
|
||||
/** Price movement direction: 'up' for gainers, 'down' for losers */
|
||||
public readonly direction: 'up' | 'down',
|
||||
/** Look-back period string, e.g. '7d', '14d', '30d' */
|
||||
public readonly period: string,
|
||||
/** Maximum number of results to return */
|
||||
public readonly limit: number,
|
||||
/** Geographic aggregation level — currently only 'district' */
|
||||
public readonly level: 'district',
|
||||
) {}
|
||||
}
|
||||
@@ -1,27 +1,30 @@
|
||||
import { HttpStatus, Inject } from '@nestjs/common';
|
||||
import { QueryBus, QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, ErrorCode, LoggerService } from '@modules/shared';
|
||||
import { SystemSettingsService } from '@modules/admin';
|
||||
import {
|
||||
PROJECT_REPOSITORY,
|
||||
type IProjectRepository,
|
||||
type ProjectDetailData,
|
||||
} from '@modules/projects';
|
||||
import { type AnthropicUsage } from '../_shared/ai-json-client';
|
||||
import {
|
||||
AI_CONFIG_PROVIDER,
|
||||
DomainException,
|
||||
ErrorCode,
|
||||
type IAIConfigProvider,
|
||||
LoggerService,
|
||||
} from '@modules/shared';
|
||||
import { type NeighborhoodScoreResult } from '../../../domain/services/neighborhood-score.service';
|
||||
import { type AnthropicUsage,
|
||||
asString,
|
||||
asStringArray,
|
||||
callAnthropicJson,
|
||||
isRecord,
|
||||
jsonShapeError,
|
||||
parseJsonObject,
|
||||
} from '../_shared/ai-json-client';
|
||||
parseJsonObject } from '../_shared/ai-json-client';
|
||||
import {
|
||||
type NearbyPOIDto,
|
||||
type NearbyPOIsResultDto,
|
||||
} from '../get-nearby-pois/get-nearby-pois.handler';
|
||||
import { GetNearbyPOIsQuery } from '../get-nearby-pois/get-nearby-pois.query';
|
||||
import { type NeighborhoodScoreResult } from '../../../domain/services/neighborhood-score.service';
|
||||
import { GetNeighborhoodScoreQuery } from '../get-neighborhood-score/get-neighborhood-score.query';
|
||||
import { GetProjectAiAdviceQuery } from './get-project-ai-advice.query';
|
||||
|
||||
@@ -75,7 +78,8 @@ export class GetProjectAiAdviceHandler
|
||||
@Inject(PROJECT_REPOSITORY)
|
||||
private readonly projectRepo: IProjectRepository,
|
||||
private readonly queryBus: QueryBus,
|
||||
private readonly systemSettings: SystemSettingsService,
|
||||
@Inject(AI_CONFIG_PROVIDER)
|
||||
private readonly aiConfig: IAIConfigProvider,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@@ -96,7 +100,7 @@ export class GetProjectAiAdviceHandler
|
||||
this.fetchScore(project),
|
||||
]);
|
||||
|
||||
const settings = await this.systemSettings.getAiSettings();
|
||||
const settings = await this.aiConfig.getAiConfig();
|
||||
if (!settings.apiKey) {
|
||||
throw new DomainException(
|
||||
ErrorCode.AI_NOT_CONFIGURED,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, CacheService, CachePrefix, CacheTTL, Cacheable, LoggerService, PrismaService } from '@modules/shared';
|
||||
import { GetTrendingAreasQuery } from './get-trending-areas.query';
|
||||
|
||||
export interface TrendingAreaItem {
|
||||
districtId: string;
|
||||
name: string;
|
||||
listings: number;
|
||||
inquiries: number;
|
||||
views: number;
|
||||
priceChangePct: number | null;
|
||||
scoreRank: number;
|
||||
}
|
||||
|
||||
export interface TrendingAreasDto {
|
||||
period: number;
|
||||
level: string;
|
||||
limit: number;
|
||||
areas: TrendingAreaItem[];
|
||||
}
|
||||
|
||||
interface RawDistrictRow {
|
||||
district: string;
|
||||
new_listings: bigint;
|
||||
inquiries: bigint;
|
||||
views: bigint;
|
||||
}
|
||||
|
||||
@QueryHandler(GetTrendingAreasQuery)
|
||||
export class GetTrendingAreasHandler implements IQueryHandler<GetTrendingAreasQuery> {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@Cacheable({
|
||||
prefix: CachePrefix.TRENDING_AREAS,
|
||||
ttl: CacheTTL.TRENDING_AREAS,
|
||||
resource: 'trending_areas',
|
||||
keyFrom: (query: unknown) => {
|
||||
const q = query as GetTrendingAreasQuery;
|
||||
return [String(q.period), String(q.limit), q.level];
|
||||
},
|
||||
})
|
||||
async execute(query: GetTrendingAreasQuery): Promise<TrendingAreasDto> {
|
||||
const { period, limit, level } = query;
|
||||
|
||||
try {
|
||||
const since = new Date(Date.now() - period * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Aggregate new listings, inquiries, and views per district within the time window.
|
||||
// Listing.viewCount is a running total so we use it as a proxy for views.
|
||||
// Inquiry has createdAt that we can filter on.
|
||||
// New listings = listings created within the window.
|
||||
const rows = await this.prisma.$queryRaw<RawDistrictRow[]>`
|
||||
SELECT
|
||||
p.district,
|
||||
COUNT(DISTINCT l.id) AS new_listings,
|
||||
COUNT(DISTINCT i.id) AS inquiries,
|
||||
COALESCE(SUM(l."viewCount"), 0) AS views
|
||||
FROM "Listing" l
|
||||
INNER JOIN "Property" p ON p.id = l."propertyId"
|
||||
LEFT JOIN "Inquiry" i ON i."listingId" = l.id AND i."createdAt" >= ${since}
|
||||
WHERE l."createdAt" >= ${since}
|
||||
AND l.status = 'ACTIVE'
|
||||
GROUP BY p.district
|
||||
`;
|
||||
|
||||
// Compute score for each district
|
||||
const scored = rows.map((r) => {
|
||||
const listings = Number(r.new_listings);
|
||||
const inquiries = Number(r.inquiries);
|
||||
const views = Number(r.views);
|
||||
const score = inquiries * 0.6 + views * 0.3 + listings * 0.1;
|
||||
return { district: r.district, listings, inquiries, views, score };
|
||||
});
|
||||
|
||||
// Sort descending by score, take top `limit`
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
const top = scored.slice(0, limit);
|
||||
|
||||
// Fetch price change (yoyChange) from MarketIndex for these districts
|
||||
const districts = top.map((r) => r.district);
|
||||
const marketIndexes = districts.length > 0
|
||||
? await this.prisma.marketIndex.findMany({
|
||||
where: { district: { in: districts } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { district: true, yoyChange: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
// Build a map district → most recent yoyChange
|
||||
const priceMap = new Map<string, number | null>();
|
||||
for (const mi of marketIndexes) {
|
||||
if (!priceMap.has(mi.district)) {
|
||||
priceMap.set(mi.district, mi.yoyChange);
|
||||
}
|
||||
}
|
||||
|
||||
const areas: TrendingAreaItem[] = top.map((r, idx) => ({
|
||||
districtId: r.district,
|
||||
name: r.district,
|
||||
listings: r.listings,
|
||||
inquiries: r.inquiries,
|
||||
views: r.views,
|
||||
priceChangePct: priceMap.get(r.district) ?? null,
|
||||
scoreRank: idx + 1,
|
||||
}));
|
||||
|
||||
return { period, level, limit, areas };
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to truy vấn trending areas: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException(
|
||||
'Không thể truy vấn khu vực xu hướng. Vui lòng thử lại sau.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export class GetTrendingAreasQuery {
|
||||
constructor(
|
||||
/** Number of days to look back, e.g. 7 | 14 | 30 */
|
||||
public readonly period: number,
|
||||
/** Maximum number of results to return */
|
||||
public readonly limit: number,
|
||||
/** Geographic level of aggregation — currently only 'district' is supported */
|
||||
public readonly level: 'district',
|
||||
) {}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { type PropertyType } from '@prisma/client';
|
||||
import { type MarketIndexEntity } from '../entities/market-index.entity';
|
||||
|
||||
export const MARKET_INDEX_REPOSITORY = Symbol('MARKET_INDEX_REPOSITORY');
|
||||
|
||||
export interface MarketReportResult {
|
||||
@@ -25,6 +24,27 @@ export interface HeatmapDataPoint {
|
||||
medianPrice: string;
|
||||
}
|
||||
|
||||
/** [TEC-3055] Ward-level heatmap data point */
|
||||
export interface WardHeatmapDataPoint {
|
||||
ward: string;
|
||||
district: string;
|
||||
city: string;
|
||||
avgPriceM2: number;
|
||||
totalListings: number;
|
||||
medianPrice: string;
|
||||
}
|
||||
|
||||
/** [TEC-3055] Ward-level listing volume result */
|
||||
export interface ListingVolumeWardResult {
|
||||
ward: string;
|
||||
district: string;
|
||||
city: string;
|
||||
period: string;
|
||||
totalListings: number;
|
||||
avgPriceM2: number;
|
||||
medianPrice: string;
|
||||
}
|
||||
|
||||
export interface PriceTrendPoint {
|
||||
period: string;
|
||||
medianPrice: string;
|
||||
@@ -45,6 +65,15 @@ export interface DistrictStatsResult {
|
||||
yoyChange: number | null;
|
||||
}
|
||||
|
||||
export interface MarketHistoryPoint {
|
||||
date: string;
|
||||
avgPrice: number;
|
||||
medianPrice: string;
|
||||
listingsCount: number;
|
||||
inquiriesCount: number;
|
||||
daysOnMarket: number;
|
||||
}
|
||||
|
||||
export interface IMarketIndexRepository {
|
||||
findById(id: string): Promise<MarketIndexEntity | null>;
|
||||
findByKey(district: string, city: string, propertyType: PropertyType, period: string): Promise<MarketIndexEntity | null>;
|
||||
@@ -52,6 +81,11 @@ export interface IMarketIndexRepository {
|
||||
update(entity: MarketIndexEntity): Promise<void>;
|
||||
getMarketReport(city: string, period: string, propertyType?: PropertyType): Promise<MarketReportResult[]>;
|
||||
getHeatmap(city: string, period: string): Promise<HeatmapDataPoint[]>;
|
||||
/** [TEC-3055] Ward-level heatmap tile aggregation */
|
||||
getHeatmapWard(city: string, period: string, district?: string): Promise<WardHeatmapDataPoint[]>;
|
||||
/** [TEC-3055] Listing volume + avg price by ward for a time period */
|
||||
getListingVolumeByWard(wardId: string, period: string): Promise<ListingVolumeWardResult | null>;
|
||||
getPriceTrend(district: string, city: string, propertyType: PropertyType, periods: string[]): Promise<PriceTrendPoint[]>;
|
||||
getDistrictStats(city: string, period: string): Promise<DistrictStatsResult[]>;
|
||||
getMarketHistory(city: string, periods: string[]): Promise<MarketHistoryPoint[]>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export { AnalyticsModule } from './analytics.module';
|
||||
export { MARKET_INDEX_REPOSITORY, IMarketIndexRepository } from './domain/repositories/market-index.repository';
|
||||
export { VALUATION_REPOSITORY, IValuationRepository } from './domain/repositories/valuation.repository';
|
||||
export { AVM_SERVICE } from './domain/services/avm-service';
|
||||
export type { IAVMService, AVMParams, ValuationResult } from './domain/services/avm-service';
|
||||
|
||||
@@ -4,11 +4,16 @@ import {
|
||||
PrismaNeighborhoodScoreService,
|
||||
} from '../services/neighborhood-score.service';
|
||||
|
||||
// Helper: build the flat $queryRaw row list that countPOIs expects.
|
||||
function makePoiRows(counts: Record<string, number>) {
|
||||
return Object.entries(counts).map(([type, n]) => ({ type, count: BigInt(n) }));
|
||||
}
|
||||
|
||||
describe('NeighborhoodScoreServiceImpl', () => {
|
||||
let service: NeighborhoodScoreServiceImpl;
|
||||
let mockPrisma: {
|
||||
neighborhoodScore: { findUnique: ReturnType<typeof vi.fn>; upsert: ReturnType<typeof vi.fn> };
|
||||
pOI: { count: ReturnType<typeof vi.fn> };
|
||||
$queryRaw: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn> };
|
||||
|
||||
@@ -18,7 +23,7 @@ describe('NeighborhoodScoreServiceImpl', () => {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
pOI: { count: vi.fn() },
|
||||
$queryRaw: vi.fn(),
|
||||
};
|
||||
mockLogger = { log: vi.fn() };
|
||||
|
||||
@@ -60,44 +65,45 @@ describe('NeighborhoodScoreServiceImpl', () => {
|
||||
});
|
||||
|
||||
describe('calculateAndSave', () => {
|
||||
it('calculates scores from POI counts and upserts', async () => {
|
||||
// Simulate POI counts: education=15 (max), healthcare=4 (50%), transport=6 (50%),
|
||||
// shopping=5 (50%), greenery=3 (50%), safety=2 (50%)
|
||||
const poiCountsByCategory = [15, 4, 6, 5, 3, 2];
|
||||
let callIndex = 0;
|
||||
mockPrisma.pOI.count.mockImplementation(() => {
|
||||
return Promise.resolve(poiCountsByCategory[callIndex++]!);
|
||||
});
|
||||
|
||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => {
|
||||
return Promise.resolve(create);
|
||||
});
|
||||
it('issues exactly one DB query and calculates scores correctly', async () => {
|
||||
mockPrisma.$queryRaw.mockResolvedValue(
|
||||
makePoiRows({
|
||||
SCHOOL: 10, UNIVERSITY: 5,
|
||||
HOSPITAL: 2, CLINIC: 2,
|
||||
METRO_STATION: 3, BUS_STOP: 3,
|
||||
MALL: 2, MARKET: 2, SUPERMARKET: 1,
|
||||
PARK: 3,
|
||||
POLICE_STATION: 1, FIRE_STATION: 1,
|
||||
}),
|
||||
);
|
||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
|
||||
Promise.resolve(create),
|
||||
);
|
||||
|
||||
const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
||||
|
||||
// education: 15/15 * 10 = 10 → 10 * 20/10 = 20
|
||||
// healthcare: 4/8 * 10 = 5 → 5 * 20/10 = 10
|
||||
// transport: 6/12 * 10 = 5 → 5 * 20/10 = 10
|
||||
// shopping: 5/10 * 10 = 5 → 5 * 15/10 = 7.5
|
||||
// greenery: 3/6 * 10 = 5 → 5 * 15/10 = 7.5
|
||||
// safety: 2/4 * 10 = 5 → 5 * 10/10 = 5
|
||||
// total = 20 + 10 + 10 + 7.5 + 7.5 + 5 = 60
|
||||
expect(result.educationScore).toBe(10);
|
||||
expect(result.healthcareScore).toBe(5);
|
||||
expect(result.totalScore).toBe(60);
|
||||
// Assert single DB round-trip for all 6 categories
|
||||
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrisma.neighborhoodScore.upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('caps category scores at 10', async () => {
|
||||
// All categories have way more POIs than max
|
||||
mockPrisma.pOI.count.mockResolvedValue(100);
|
||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => {
|
||||
return Promise.resolve(create);
|
||||
});
|
||||
mockPrisma.$queryRaw.mockResolvedValue(
|
||||
makePoiRows({
|
||||
SCHOOL: 100, UNIVERSITY: 100, HOSPITAL: 100, CLINIC: 100,
|
||||
METRO_STATION: 100, BUS_STOP: 100, MALL: 100, MARKET: 100,
|
||||
SUPERMARKET: 100, PARK: 100, POLICE_STATION: 100, FIRE_STATION: 100,
|
||||
}),
|
||||
);
|
||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
|
||||
Promise.resolve(create),
|
||||
);
|
||||
|
||||
const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
||||
|
||||
// All scores capped at 10 → total = sum of weights = 100
|
||||
expect(result.educationScore).toBe(10);
|
||||
expect(result.healthcareScore).toBe(10);
|
||||
expect(result.transportScore).toBe(10);
|
||||
@@ -105,25 +111,27 @@ describe('NeighborhoodScoreServiceImpl', () => {
|
||||
expect(result.greeneryScore).toBe(10);
|
||||
expect(result.safetyScore).toBe(10);
|
||||
expect(result.totalScore).toBe(100);
|
||||
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns 0 scores when no POIs exist', async () => {
|
||||
mockPrisma.pOI.count.mockResolvedValue(0);
|
||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => {
|
||||
return Promise.resolve(create);
|
||||
});
|
||||
mockPrisma.$queryRaw.mockResolvedValue([]);
|
||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
|
||||
Promise.resolve(create),
|
||||
);
|
||||
|
||||
const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
||||
|
||||
expect(result.educationScore).toBe(0);
|
||||
expect(result.totalScore).toBe(0);
|
||||
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('logs the calculated score', async () => {
|
||||
mockPrisma.pOI.count.mockResolvedValue(5);
|
||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => {
|
||||
return Promise.resolve(create);
|
||||
});
|
||||
mockPrisma.$queryRaw.mockResolvedValue(makePoiRows({ SCHOOL: 5 }));
|
||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
|
||||
Promise.resolve(create),
|
||||
);
|
||||
|
||||
await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
||||
|
||||
@@ -140,7 +148,7 @@ describe('HttpNeighborhoodScoreService', () => {
|
||||
let prismaFallback: PrismaNeighborhoodScoreService;
|
||||
let mockPrisma: {
|
||||
neighborhoodScore: { findUnique: ReturnType<typeof vi.fn>; upsert: ReturnType<typeof vi.fn> };
|
||||
pOI: { count: ReturnType<typeof vi.fn> };
|
||||
$queryRaw: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
|
||||
let mockAiClient: { scoreNeighborhood: ReturnType<typeof vi.fn> };
|
||||
@@ -148,7 +156,7 @@ describe('HttpNeighborhoodScoreService', () => {
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
neighborhoodScore: { findUnique: vi.fn(), upsert: vi.fn() },
|
||||
pOI: { count: vi.fn() },
|
||||
$queryRaw: vi.fn(),
|
||||
};
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn() };
|
||||
mockAiClient = { scoreNeighborhood: vi.fn() };
|
||||
@@ -165,7 +173,7 @@ describe('HttpNeighborhoodScoreService', () => {
|
||||
});
|
||||
|
||||
it('persists AI service response when scoreNeighborhood succeeds', async () => {
|
||||
mockPrisma.pOI.count.mockResolvedValue(6);
|
||||
mockPrisma.$queryRaw.mockResolvedValue(makePoiRows({ SCHOOL: 6 }));
|
||||
mockAiClient.scoreNeighborhood.mockResolvedValue({
|
||||
district: 'Quận 1',
|
||||
city: 'Hồ Chí Minh',
|
||||
@@ -179,7 +187,9 @@ describe('HttpNeighborhoodScoreService', () => {
|
||||
poi_counts: { education: 6, healthcare: 6, transport: 6, shopping: 6, greenery: 6, safety: 6 },
|
||||
algorithm_version: 'neighborhood-heuristic-v1',
|
||||
});
|
||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => Promise.resolve(create));
|
||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
|
||||
Promise.resolve(create),
|
||||
);
|
||||
|
||||
const result = await httpService.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
||||
|
||||
@@ -187,12 +197,15 @@ describe('HttpNeighborhoodScoreService', () => {
|
||||
expect(result.totalScore).toBe(71.2);
|
||||
expect(result.educationScore).toBe(8.5);
|
||||
expect(mockPrisma.neighborhoodScore.upsert).toHaveBeenCalledOnce();
|
||||
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to prisma scoring when AI service throws', async () => {
|
||||
mockPrisma.pOI.count.mockResolvedValue(0);
|
||||
mockPrisma.$queryRaw.mockResolvedValue([]);
|
||||
mockAiClient.scoreNeighborhood.mockRejectedValue(new Error('AI service down'));
|
||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => Promise.resolve(create));
|
||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
|
||||
Promise.resolve(create),
|
||||
);
|
||||
|
||||
const result = await httpService.calculateAndSave('Quận 7', 'Hồ Chí Minh');
|
||||
|
||||
|
||||
@@ -29,12 +29,15 @@ describe('PrismaAVMService', () => {
|
||||
});
|
||||
|
||||
it('returns zero confidence when fewer than 3 comparables', async () => {
|
||||
mockPrisma.$queryRaw.mockResolvedValue([
|
||||
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
||||
]);
|
||||
mockPrisma.$queryRawUnsafe.mockResolvedValue([
|
||||
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
||||
]);
|
||||
// First $queryRaw call: property location lookup
|
||||
// Second $queryRaw call: findComparables (parameterized after refactor in 6774914)
|
||||
mockPrisma.$queryRaw
|
||||
.mockResolvedValueOnce([
|
||||
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
||||
]);
|
||||
|
||||
const result = await service.estimateValue({ propertyId: 'prop-1' });
|
||||
|
||||
@@ -44,14 +47,15 @@ describe('PrismaAVMService', () => {
|
||||
});
|
||||
|
||||
it('calculates weighted valuation with sufficient comparables', async () => {
|
||||
mockPrisma.$queryRaw.mockResolvedValue([
|
||||
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
||||
]);
|
||||
mockPrisma.$queryRawUnsafe.mockResolvedValue([
|
||||
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
||||
{ property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() },
|
||||
{ property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() },
|
||||
]);
|
||||
mockPrisma.$queryRaw
|
||||
.mockResolvedValueOnce([
|
||||
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
||||
{ property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() },
|
||||
{ property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() },
|
||||
]);
|
||||
|
||||
const result = await service.estimateValue({ propertyId: 'prop-1' });
|
||||
|
||||
@@ -63,7 +67,8 @@ describe('PrismaAVMService', () => {
|
||||
});
|
||||
|
||||
it('uses coordinates directly when no propertyId', async () => {
|
||||
mockPrisma.$queryRawUnsafe.mockResolvedValue([
|
||||
// coords-only path: no property lookup, $queryRaw used for comparables directly
|
||||
mockPrisma.$queryRaw.mockResolvedValue([
|
||||
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
||||
{ property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() },
|
||||
{ property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() },
|
||||
@@ -78,18 +83,20 @@ describe('PrismaAVMService', () => {
|
||||
|
||||
expect(result.confidence).toBeGreaterThan(0);
|
||||
expect(Number(result.estimatedPrice)).toBeGreaterThan(0);
|
||||
expect(mockPrisma.$queryRaw).not.toHaveBeenCalled();
|
||||
// coords-only path: $queryRaw is used for comparables; $queryRawUnsafe not called
|
||||
expect(mockPrisma.$queryRawUnsafe).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getComparables', () => {
|
||||
it('returns comparables for a property', async () => {
|
||||
mockPrisma.$queryRaw.mockResolvedValue([
|
||||
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
||||
]);
|
||||
mockPrisma.$queryRawUnsafe.mockResolvedValue([
|
||||
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 200, published_at: new Date() },
|
||||
]);
|
||||
mockPrisma.$queryRaw
|
||||
.mockResolvedValueOnce([
|
||||
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 200, published_at: new Date() },
|
||||
]);
|
||||
|
||||
const result = await service.getComparables('prop-1', 3000);
|
||||
|
||||
|
||||
@@ -6,8 +6,11 @@ import {
|
||||
type IMarketIndexRepository,
|
||||
type MarketReportResult,
|
||||
type HeatmapDataPoint,
|
||||
type WardHeatmapDataPoint,
|
||||
type ListingVolumeWardResult,
|
||||
type PriceTrendPoint,
|
||||
type DistrictStatsResult,
|
||||
type MarketHistoryPoint,
|
||||
} from '../../domain/repositories/market-index.repository';
|
||||
|
||||
@Injectable()
|
||||
@@ -129,6 +132,112 @@ export class PrismaMarketIndexRepository implements IMarketIndexRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* [TEC-3055] Ward-level heatmap.
|
||||
* Aggregates active listings directly from the Property/Listing tables using
|
||||
* PostGIS-friendly Prisma raw queries. Falls back to an in-memory group-by so
|
||||
* the method is testable without PostGIS extension.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Join Property → Listing (status=ACTIVE) filtered by city + optionally district.
|
||||
* 2. Group by (ward, district) — compute avg(pricePerM2), count, and sort by ward asc.
|
||||
* 3. Cache handled upstream by the handler (30 min TTL).
|
||||
*/
|
||||
async getHeatmapWard(city: string, _period: string, district?: string): Promise<WardHeatmapDataPoint[]> {
|
||||
type WardRow = { ward: string; district: string; avg_price_m2: number; total_listings: bigint; median_price: bigint };
|
||||
|
||||
const rows = district
|
||||
? await this.prisma.$queryRaw<WardRow[]>`
|
||||
SELECT
|
||||
p."ward",
|
||||
p."district",
|
||||
AVG(l."priceVND" / NULLIF(p."areaM2", 0))::float8 AS avg_price_m2,
|
||||
COUNT(l."id")::bigint AS total_listings,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l."priceVND")::bigint AS median_price
|
||||
FROM "Property" p
|
||||
JOIN "Listing" l ON l."propertyId" = p."id" AND l."status" = 'ACTIVE'
|
||||
WHERE p."city" = ${city} AND p."district" = ${district}
|
||||
AND p."ward" IS NOT NULL AND p."ward" != ''
|
||||
GROUP BY p."ward", p."district"
|
||||
ORDER BY p."ward" ASC
|
||||
`
|
||||
: await this.prisma.$queryRaw<WardRow[]>`
|
||||
SELECT
|
||||
p."ward",
|
||||
p."district",
|
||||
AVG(l."priceVND" / NULLIF(p."areaM2", 0))::float8 AS avg_price_m2,
|
||||
COUNT(l."id")::bigint AS total_listings,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l."priceVND")::bigint AS median_price
|
||||
FROM "Property" p
|
||||
JOIN "Listing" l ON l."propertyId" = p."id" AND l."status" = 'ACTIVE'
|
||||
WHERE p."city" = ${city}
|
||||
AND p."ward" IS NOT NULL AND p."ward" != ''
|
||||
GROUP BY p."ward", p."district"
|
||||
ORDER BY p."ward" ASC
|
||||
`;
|
||||
|
||||
return rows.map((r) => ({
|
||||
ward: r.ward,
|
||||
district: r.district,
|
||||
city,
|
||||
avgPriceM2: r.avg_price_m2 ?? 0,
|
||||
totalListings: Number(r.total_listings),
|
||||
medianPrice: (r.median_price ?? BigInt(0)).toString(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* [TEC-3055] Listing volume + price aggregation for a specific ward over a period.
|
||||
* `wardId` is treated as the ward string (Property.ward) since the schema stores ward
|
||||
* as a plain string column (no separate Ward FK at this point).
|
||||
* `period` format: "YYYY-QN" (quarterly) or "YYYY-MM" (monthly) — matched against
|
||||
* the period column on MarketIndex (where available) or derived from Listing.createdAt.
|
||||
*/
|
||||
async getListingVolumeByWard(wardId: string, period: string): Promise<ListingVolumeWardResult | null> {
|
||||
// Derive date range from period string (e.g. "2026-Q1" → Jan-Mar 2026, "2026-03" → Mar 2026)
|
||||
const dateRange = this.periodToDateRange(period);
|
||||
if (!dateRange) return null;
|
||||
|
||||
type VolumeRow = {
|
||||
ward: string;
|
||||
district: string;
|
||||
city: string;
|
||||
total_listings: bigint;
|
||||
avg_price_m2: number;
|
||||
median_price: bigint;
|
||||
};
|
||||
|
||||
const rows = await this.prisma.$queryRawUnsafe<VolumeRow[]>(`
|
||||
SELECT
|
||||
p."ward",
|
||||
p."district",
|
||||
p."city",
|
||||
COUNT(l."id")::bigint AS total_listings,
|
||||
AVG(l."priceVND" / NULLIF(p."areaM2", 0))::float8 AS avg_price_m2,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l."priceVND")::bigint AS median_price
|
||||
FROM "Property" p
|
||||
JOIN "Listing" l ON l."propertyId" = p."id"
|
||||
WHERE p."ward" = $1
|
||||
AND l."createdAt" >= $2
|
||||
AND l."createdAt" < $3
|
||||
GROUP BY p."ward", p."district", p."city"
|
||||
LIMIT 1
|
||||
`, wardId, dateRange.start, dateRange.end);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
const r = rows[0]!;
|
||||
|
||||
return {
|
||||
ward: r.ward,
|
||||
district: r.district,
|
||||
city: r.city,
|
||||
period,
|
||||
totalListings: Number(r.total_listings),
|
||||
avgPriceM2: r.avg_price_m2 ?? 0,
|
||||
medianPrice: (r.median_price ?? BigInt(0)).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
async getPriceTrend(
|
||||
district: string,
|
||||
city: string,
|
||||
@@ -173,6 +282,83 @@ export class PrismaMarketIndexRepository implements IMarketIndexRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
async getMarketHistory(city: string, periods: string[]): Promise<MarketHistoryPoint[]> {
|
||||
const records = await this.prisma.marketIndex.findMany({
|
||||
where: {
|
||||
city: { equals: city, mode: 'insensitive' },
|
||||
period: { in: periods },
|
||||
},
|
||||
orderBy: { period: 'asc' },
|
||||
});
|
||||
|
||||
// Aggregate across all districts/property types per period
|
||||
const periodMap = new Map<string, {
|
||||
totalAvgPrice: number;
|
||||
totalMedian: bigint;
|
||||
totalListings: number;
|
||||
totalDaysOnMarket: number;
|
||||
count: number;
|
||||
}>();
|
||||
|
||||
for (const r of records) {
|
||||
const existing = periodMap.get(r.period);
|
||||
if (existing) {
|
||||
existing.totalAvgPrice += r.avgPriceM2;
|
||||
existing.totalMedian += r.medianPrice;
|
||||
existing.totalListings += r.totalListings;
|
||||
existing.totalDaysOnMarket += r.daysOnMarket;
|
||||
existing.count++;
|
||||
} else {
|
||||
periodMap.set(r.period, {
|
||||
totalAvgPrice: r.avgPriceM2,
|
||||
totalMedian: r.medianPrice,
|
||||
totalListings: r.totalListings,
|
||||
totalDaysOnMarket: r.daysOnMarket,
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(periodMap.entries()).map(([period, data]) => ({
|
||||
date: period,
|
||||
avgPrice: Math.round(data.totalAvgPrice / data.count),
|
||||
medianPrice: (data.totalMedian / BigInt(data.count)).toString(),
|
||||
listingsCount: data.totalListings,
|
||||
inquiriesCount: 0, // inquiries not tracked in MarketIndex
|
||||
daysOnMarket: Math.round(data.totalDaysOnMarket / data.count),
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Parse period strings like "2026-Q1", "2026-03" into an inclusive date range. */
|
||||
private periodToDateRange(period: string): { start: Date; end: Date } | null {
|
||||
// Quarterly: YYYY-Q1 … YYYY-Q4
|
||||
const quarterly = /^(\d{4})-Q([1-4])$/.exec(period);
|
||||
if (quarterly) {
|
||||
const year = Number(quarterly[1]);
|
||||
const quarter = Number(quarterly[2]);
|
||||
const startMonth = (quarter - 1) * 3; // 0-based
|
||||
const start = new Date(Date.UTC(year, startMonth, 1));
|
||||
const end = new Date(Date.UTC(year, startMonth + 3, 1));
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
// Monthly: YYYY-MM
|
||||
const monthly = /^(\d{4})-(\d{2})$/.exec(period);
|
||||
if (monthly) {
|
||||
const year = Number(monthly[1]);
|
||||
const month = Number(monthly[2]) - 1; // 0-based
|
||||
const start = new Date(Date.UTC(year, month, 1));
|
||||
const end = new Date(Date.UTC(year, month + 1, 1));
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private toDomain(raw: PrismaMarketIndex): MarketIndexEntity {
|
||||
const props: MarketIndexProps = {
|
||||
district: raw.district,
|
||||
|
||||
@@ -280,7 +280,7 @@ interface RawTrainingRow {
|
||||
price_vnd: number;
|
||||
}
|
||||
|
||||
interface TrainingRow extends RawTrainingRow {}
|
||||
type TrainingRow = RawTrainingRow;
|
||||
|
||||
interface RetrainResult {
|
||||
model_version: string;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type AiPredictRequest,
|
||||
type AiPredictV2Request,
|
||||
} from './ai-service.client';
|
||||
import { PrismaAVMService } from './prisma-avm.service';
|
||||
|
||||
/** Map string risk buckets to the 0..1 float the Python service expects. */
|
||||
const FLOOD_RISK_TO_SCORE: Record<string, number> = {
|
||||
@@ -22,7 +23,6 @@ const FLOOD_RISK_TO_SCORE: Record<string, number> = {
|
||||
MEDIUM: 0.66,
|
||||
HIGH: 1,
|
||||
};
|
||||
import { PrismaAVMService } from './prisma-avm.service';
|
||||
|
||||
/** Max concurrency for batch AI calls to avoid overloading the Python service. */
|
||||
const BATCH_CONCURRENCY = 5;
|
||||
|
||||
@@ -3,3 +3,10 @@ export { HttpAVMService } from './http-avm.service';
|
||||
export { AiServiceClient, AI_SERVICE_CLIENT } from './ai-service.client';
|
||||
export type { IAiServiceClient, AiPredictRequest, AiPredictResponse, AiModerationRequest, AiModerationResponse } from './ai-service.client';
|
||||
export { MarketIndexCronService } from './market-index-cron.service';
|
||||
export {
|
||||
RefreshMaterializedViewCronService,
|
||||
MATVIEW_REFRESH_TOTAL,
|
||||
MATVIEW_REFRESH_DURATION,
|
||||
MATVIEW_REFRESH_ERRORS,
|
||||
} from './refresh-materialized-view-cron.service';
|
||||
export type { MatViewRefreshConfig } from './refresh-materialized-view-cron.service';
|
||||
|
||||
@@ -143,18 +143,26 @@ async function countPOIs(
|
||||
district: string,
|
||||
city: string,
|
||||
): Promise<AiNeighborhoodPOICounts> {
|
||||
const entries = await Promise.all(
|
||||
CATEGORY_KEYS.map(async (cat) => {
|
||||
const count = await prisma.pOI.count({
|
||||
where: {
|
||||
district,
|
||||
city,
|
||||
type: { in: CATEGORY_POI_TYPES[cat] },
|
||||
},
|
||||
});
|
||||
return [cat, count] as const;
|
||||
}),
|
||||
);
|
||||
// Single GROUP BY query replaces 6x individual COUNT queries.
|
||||
const rows = await prisma.$queryRaw<{ type: POIType; count: bigint }[]>`
|
||||
SELECT "type", COUNT(*) AS count
|
||||
FROM "POI"
|
||||
WHERE "district" = ${district} AND "city" = ${city}
|
||||
GROUP BY "type"
|
||||
`;
|
||||
|
||||
const typeCountMap = new Map<POIType, number>();
|
||||
for (const row of rows) {
|
||||
typeCountMap.set(row.type, Number(row.count));
|
||||
}
|
||||
|
||||
const entries = CATEGORY_KEYS.map((cat) => {
|
||||
const total = CATEGORY_POI_TYPES[cat].reduce(
|
||||
(sum, t) => sum + (typeCountMap.get(t) ?? 0),
|
||||
0,
|
||||
);
|
||||
return [cat, total] as const;
|
||||
});
|
||||
|
||||
return Object.fromEntries(entries) as unknown as AiNeighborhoodPOICounts;
|
||||
}
|
||||
|
||||
@@ -136,23 +136,35 @@ export class PrismaAVMService implements IAVMService {
|
||||
propertyType: PropertyType | undefined,
|
||||
radiusMeters: number,
|
||||
): Promise<RawComparable[]> {
|
||||
const typeFilter = propertyType ? `AND p."propertyType" = '${propertyType}'` : '';
|
||||
return this.prisma.$queryRawUnsafe<RawComparable[]>(
|
||||
`
|
||||
if (propertyType) {
|
||||
return this.prisma.$queryRaw<RawComparable[]>`
|
||||
SELECT
|
||||
p.id AS property_id, p.address, p.district,
|
||||
l."priceVND" AS price_vnd, l."pricePerM2" AS price_per_m2,
|
||||
p."areaM2" AS area_m2, p."propertyType" AS property_type,
|
||||
ST_Distance(p.location::geography, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography) AS distance_meters,
|
||||
l."publishedAt" AS published_at
|
||||
FROM "Property" p
|
||||
JOIN "Listing" l ON l."propertyId" = p.id
|
||||
WHERE l.status = 'ACTIVE' AND l."publishedAt" IS NOT NULL
|
||||
AND ST_DWithin(p.location::geography, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography, ${radiusMeters})
|
||||
AND p."propertyType" = ${propertyType}::"PropertyType"
|
||||
ORDER BY distance_meters ASC LIMIT 20
|
||||
`;
|
||||
}
|
||||
|
||||
return this.prisma.$queryRaw<RawComparable[]>`
|
||||
SELECT
|
||||
p.id AS property_id, p.address, p.district,
|
||||
l."priceVND" AS price_vnd, l."pricePerM2" AS price_per_m2,
|
||||
p."areaM2" AS area_m2, p."propertyType" AS property_type,
|
||||
ST_Distance(p.location::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography) AS distance_meters,
|
||||
ST_Distance(p.location::geography, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography) AS distance_meters,
|
||||
l."publishedAt" AS published_at
|
||||
FROM "Property" p
|
||||
JOIN "Listing" l ON l."propertyId" = p.id
|
||||
WHERE l.status = 'ACTIVE' AND l."publishedAt" IS NOT NULL
|
||||
AND ST_DWithin(p.location::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, $3)
|
||||
${typeFilter}
|
||||
AND ST_DWithin(p.location::geography, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography, ${radiusMeters})
|
||||
ORDER BY distance_meters ASC LIMIT 20
|
||||
`,
|
||||
lng, lat, radiusMeters,
|
||||
);
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { Injectable, type OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { InjectMetric } from '@willsoto/nestjs-prometheus';
|
||||
import { Counter, Histogram } from 'prom-client';
|
||||
import { PrismaService, RedisService, LoggerService } from '@modules/shared';
|
||||
|
||||
/**
|
||||
* Metric names exported so modules can wire `makeCounterProvider` / `makeHistogramProvider`.
|
||||
*/
|
||||
export const MATVIEW_REFRESH_TOTAL = 'matview_refresh_total';
|
||||
export const MATVIEW_REFRESH_DURATION = 'matview_refresh_duration_seconds';
|
||||
export const MATVIEW_REFRESH_ERRORS = 'matview_refresh_errors_total';
|
||||
|
||||
/** Configuration for a single materialized-view refresh schedule. */
|
||||
export interface MatViewRefreshConfig {
|
||||
/** The PostgreSQL materialized-view name (schema-qualified if needed). */
|
||||
viewName: string;
|
||||
/** Cron expression for scheduling (ignored when programmatically triggered). */
|
||||
cron: string;
|
||||
/** Expected max duration in seconds — watchdog kills at 2×. */
|
||||
expectedDurationSeconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default views to refresh — empty in Phase 0 (no Phase 1 views yet).
|
||||
* Phase 1 will add entries here or via `MATVIEW_REFRESH_VIEWS` env var.
|
||||
*/
|
||||
const DEFAULT_VIEWS: MatViewRefreshConfig[] = [];
|
||||
|
||||
const LOCK_PREFIX = 'matview:lock:';
|
||||
const LOCK_TTL_MULTIPLIER = 2;
|
||||
|
||||
@Injectable()
|
||||
export class RefreshMaterializedViewCronService implements OnModuleDestroy {
|
||||
private readonly views: MatViewRefreshConfig[];
|
||||
/** Track in-flight AbortControllers so the watchdog can cancel them. */
|
||||
private readonly inflight = new Map<string, AbortController>();
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly logger: LoggerService,
|
||||
private readonly config: ConfigService,
|
||||
@InjectMetric(MATVIEW_REFRESH_TOTAL) private readonly refreshCounter: Counter,
|
||||
@InjectMetric(MATVIEW_REFRESH_DURATION) private readonly refreshDuration: Histogram,
|
||||
@InjectMetric(MATVIEW_REFRESH_ERRORS) private readonly refreshErrors: Counter,
|
||||
) {
|
||||
this.views = this.loadViewConfig();
|
||||
if (this.views.length > 0) {
|
||||
this.logger.log(
|
||||
`Materialized-view refresh configured for: ${this.views.map((v) => v.viewName).join(', ')}`,
|
||||
'RefreshMatView',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
// Abort any in-flight refreshes during graceful shutdown.
|
||||
for (const [view, ctrl] of this.inflight) {
|
||||
ctrl.abort();
|
||||
this.logger.warn(`Aborted in-flight refresh for ${view} (shutdown)`, 'RefreshMatView');
|
||||
}
|
||||
this.inflight.clear();
|
||||
}
|
||||
|
||||
// ─── Cron entry-point ───────────────────────────────────────────────
|
||||
// Fires every 5 minutes. Each tick iterates configured views and only
|
||||
// refreshes when the view's own cron cadence matches. Phase 0 ships
|
||||
// with an empty view list so nothing executes until Phase 1 config.
|
||||
@Cron('*/5 * * * *', { name: 'matview-refresh-tick' })
|
||||
async tick(): Promise<void> {
|
||||
for (const view of this.views) {
|
||||
await this.tryRefresh(view);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public entry for ad-hoc / test invocation.
|
||||
*/
|
||||
async refreshView(viewName: string): Promise<void> {
|
||||
const view = this.views.find((v) => v.viewName === viewName);
|
||||
if (!view) {
|
||||
throw new Error(`Unknown materialized view: ${viewName}`);
|
||||
}
|
||||
await this.executeRefresh(view);
|
||||
}
|
||||
|
||||
// ─── Core logic ─────────────────────────────────────────────────────
|
||||
|
||||
/** Acquire mutex, refresh, release. No-op when lock is held. */
|
||||
async tryRefresh(view: MatViewRefreshConfig): Promise<boolean> {
|
||||
const lockKey = `${LOCK_PREFIX}${view.viewName}`;
|
||||
const lockTtl = view.expectedDurationSeconds * LOCK_TTL_MULTIPLIER;
|
||||
|
||||
const acquired = await this.acquireLock(lockKey, lockTtl);
|
||||
if (!acquired) {
|
||||
this.logger.debug(`Skipping ${view.viewName} — lock held`, 'RefreshMatView');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.executeRefresh(view);
|
||||
return true;
|
||||
} finally {
|
||||
await this.releaseLock(lockKey);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeRefresh(view: MatViewRefreshConfig): Promise<void> {
|
||||
const watchdogMs = view.expectedDurationSeconds * LOCK_TTL_MULTIPLIER * 1000;
|
||||
const ctrl = new AbortController();
|
||||
this.inflight.set(view.viewName, ctrl);
|
||||
|
||||
const watchdog = setTimeout(() => {
|
||||
ctrl.abort();
|
||||
this.refreshErrors.inc({ view: view.viewName, reason: 'watchdog' });
|
||||
this.logger.error(
|
||||
`Watchdog killed refresh of ${view.viewName} after ${watchdogMs}ms`,
|
||||
undefined,
|
||||
'RefreshMatView',
|
||||
);
|
||||
}, watchdogMs);
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
// REFRESH MATERIALIZED VIEW CONCURRENTLY requires a unique index on the
|
||||
// view. Callers are responsible for ensuring that index exists.
|
||||
await this.prisma.$executeRawUnsafe(
|
||||
`REFRESH MATERIALIZED VIEW CONCURRENTLY "${view.viewName}"`,
|
||||
);
|
||||
|
||||
const durationSec = (Date.now() - start) / 1000;
|
||||
this.refreshCounter.inc({ view: view.viewName, status: 'success' });
|
||||
this.refreshDuration.observe({ view: view.viewName }, durationSec);
|
||||
this.logger.log(
|
||||
`Refreshed ${view.viewName} in ${durationSec.toFixed(2)}s`,
|
||||
'RefreshMatView',
|
||||
);
|
||||
} catch (err) {
|
||||
if (ctrl.signal.aborted) return; // watchdog already logged
|
||||
const durationSec = (Date.now() - start) / 1000;
|
||||
this.refreshErrors.inc({ view: view.viewName, reason: 'query' });
|
||||
this.refreshDuration.observe({ view: view.viewName }, durationSec);
|
||||
this.logger.error(
|
||||
`Failed to refresh ${view.viewName}: ${(err as Error).message}`,
|
||||
(err as Error).stack,
|
||||
'RefreshMatView',
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(watchdog);
|
||||
this.inflight.delete(view.viewName);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Redis distributed lock (SET NX EX) ─────────────────────────────
|
||||
|
||||
private async acquireLock(key: string, ttlSeconds: number): Promise<boolean> {
|
||||
if (!this.redis.isAvailable()) {
|
||||
// Fallback: allow refresh (single-instance safe, no mutex).
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const result = await this.redis.getClient().set(key, '1', 'EX', ttlSeconds, 'NX');
|
||||
return result === 'OK';
|
||||
} catch (err) {
|
||||
this.logger.warn(`Lock acquire failed for ${key}: ${(err as Error).message}`, 'RefreshMatView');
|
||||
return true; // degrade open — better to refresh than skip
|
||||
}
|
||||
}
|
||||
|
||||
private async releaseLock(key: string): Promise<void> {
|
||||
try {
|
||||
await this.redis.getClient().del(key);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Lock release failed for ${key}: ${(err as Error).message}`, 'RefreshMatView');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Config loading ─────────────────────────────────────────────────
|
||||
|
||||
private loadViewConfig(): MatViewRefreshConfig[] {
|
||||
const raw = this.config.get<string>('MATVIEW_REFRESH_VIEWS');
|
||||
if (!raw) return DEFAULT_VIEWS;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as MatViewRefreshConfig[];
|
||||
if (!Array.isArray(parsed)) throw new Error('Expected JSON array');
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Invalid MATVIEW_REFRESH_VIEWS config: ${(err as Error).message}`,
|
||||
undefined,
|
||||
'RefreshMatView',
|
||||
);
|
||||
return DEFAULT_VIEWS;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { type ExecutionContext, type CallHandler } from '@nestjs/common';
|
||||
import { of, lastValueFrom } from 'rxjs';
|
||||
import { cacheMetaStorage } from '@modules/shared';
|
||||
import { CacheMetaInterceptor, type WithCacheMeta } from '../interceptors/cache-meta.interceptor';
|
||||
|
||||
function makeContext(): ExecutionContext {
|
||||
return {} as ExecutionContext;
|
||||
}
|
||||
|
||||
function makeHandler<T>(value: T): CallHandler {
|
||||
return { handle: () => of(value) };
|
||||
}
|
||||
|
||||
describe('CacheMetaInterceptor — analytics endpoints', () => {
|
||||
let interceptor: CacheMetaInterceptor;
|
||||
|
||||
beforeEach(() => {
|
||||
interceptor = new CacheMetaInterceptor();
|
||||
});
|
||||
|
||||
it('market-report: wraps payload with cacheMeta.source=fresh when no cache was hit', async () => {
|
||||
const payload = { city: 'Hồ Chí Minh', period: '2026-Q1', districts: [] };
|
||||
const result = await lastValueFrom(
|
||||
interceptor.intercept(makeContext(), makeHandler(payload)),
|
||||
) as WithCacheMeta<typeof payload>;
|
||||
|
||||
expect(result.data).toEqual(payload);
|
||||
expect(result.cacheMeta).toMatchObject({
|
||||
source: 'fresh',
|
||||
});
|
||||
});
|
||||
|
||||
it('price-trend: wraps payload with cacheMeta.source=fresh when no cache was hit', async () => {
|
||||
const payload = { district: 'Quận 1', city: 'Hồ Chí Minh', propertyType: 'APARTMENT', trend: [] };
|
||||
const result = await lastValueFrom(
|
||||
interceptor.intercept(makeContext(), makeHandler(payload)),
|
||||
) as WithCacheMeta<typeof payload>;
|
||||
|
||||
expect(result.data).toEqual(payload);
|
||||
expect(result.cacheMeta).toMatchObject({
|
||||
source: 'fresh',
|
||||
});
|
||||
});
|
||||
|
||||
it('heatmap: wraps payload with cacheMeta.source=fresh when no cache was hit', async () => {
|
||||
const payload = { city: 'Hồ Chí Minh', period: '2026-Q1', dataPoints: [] };
|
||||
const result = await lastValueFrom(
|
||||
interceptor.intercept(makeContext(), makeHandler(payload)),
|
||||
) as WithCacheMeta<typeof payload>;
|
||||
|
||||
expect(result.data).toEqual(payload);
|
||||
expect(result.cacheMeta).toMatchObject({
|
||||
source: 'fresh',
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces cache-hit meta when store is populated by CacheService', async () => {
|
||||
const cachedAt = '2026-04-21T10:00:00.000Z';
|
||||
const nextRefreshAt = '2026-04-21T10:15:00.000Z';
|
||||
const payload = { city: 'Hồ Chí Minh', period: '2026-Q1', districts: [] };
|
||||
|
||||
// Simulate CacheService populating the store during handler execution
|
||||
const handler: CallHandler = {
|
||||
handle: () => {
|
||||
const store = cacheMetaStorage.getStore();
|
||||
if (store) {
|
||||
store.meta = { cachedAt, nextRefreshAt, source: 'cache' };
|
||||
}
|
||||
return of(payload);
|
||||
},
|
||||
};
|
||||
|
||||
const result = await lastValueFrom(
|
||||
interceptor.intercept(makeContext(), handler),
|
||||
) as WithCacheMeta<typeof payload>;
|
||||
|
||||
expect(result.cacheMeta).toEqual({ cachedAt, nextRefreshAt, source: 'cache' });
|
||||
expect(result.data).toEqual(payload);
|
||||
});
|
||||
|
||||
it('provides null cachedAt/nextRefreshAt for fresh responses', async () => {
|
||||
const result = await lastValueFrom(
|
||||
interceptor.intercept(makeContext(), makeHandler({ ok: true })),
|
||||
) as WithCacheMeta<unknown>;
|
||||
|
||||
expect(result.cacheMeta.cachedAt).toBeNull();
|
||||
expect(result.cacheMeta.nextRefreshAt).toBeNull();
|
||||
});
|
||||
|
||||
it('does not leak meta between concurrent requests (ALS isolation)', async () => {
|
||||
const cachedAt = '2026-04-21T08:00:00.000Z';
|
||||
|
||||
const handler1: CallHandler = {
|
||||
handle: () => {
|
||||
const store = cacheMetaStorage.getStore();
|
||||
if (store) store.meta = { cachedAt, nextRefreshAt: cachedAt, source: 'cache' };
|
||||
return of({ req: 1 });
|
||||
},
|
||||
};
|
||||
const handler2: CallHandler = {
|
||||
handle: () => of({ req: 2 }),
|
||||
};
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
lastValueFrom(interceptor.intercept(makeContext(), handler1)),
|
||||
lastValueFrom(interceptor.intercept(makeContext(), handler2)),
|
||||
]) as [WithCacheMeta<unknown>, WithCacheMeta<unknown>];
|
||||
|
||||
expect(r1.cacheMeta.source).toBe('cache');
|
||||
expect(r2.cacheMeta.source).toBe('fresh');
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody, ApiParam } from '@nestjs/swagger';
|
||||
@@ -22,17 +23,27 @@ import {
|
||||
type ListingAiAdviceResponse,
|
||||
} from '../../application/queries/get-listing-ai-advice/get-listing-ai-advice.handler';
|
||||
import { GetListingAiAdviceQuery } from '../../application/queries/get-listing-ai-advice/get-listing-ai-advice.query';
|
||||
import { type ListingVolumeWardDto } from '../../application/queries/get-listing-volume-ward/get-listing-volume-ward.handler';
|
||||
import { GetListingVolumeWardQuery } from '../../application/queries/get-listing-volume-ward/get-listing-volume-ward.query';
|
||||
import { type MarketHistoryDto } from '../../application/queries/get-market-history/get-market-history.handler';
|
||||
import { GetMarketHistoryQuery } from '../../application/queries/get-market-history/get-market-history.query';
|
||||
import { type MarketReportDto } from '../../application/queries/get-market-report/get-market-report.handler';
|
||||
import { GetMarketReportQuery } from '../../application/queries/get-market-report/get-market-report.query';
|
||||
import { type MarketSnapshotDto } from '../../application/queries/get-market-snapshot/get-market-snapshot.handler';
|
||||
import { GetMarketSnapshotQuery } from '../../application/queries/get-market-snapshot/get-market-snapshot.query';
|
||||
import { type NearbyPOIsResultDto } from '../../application/queries/get-nearby-pois/get-nearby-pois.handler';
|
||||
import { GetNearbyPOIsQuery } from '../../application/queries/get-nearby-pois/get-nearby-pois.query';
|
||||
import { GetNeighborhoodScoreQuery } from '../../application/queries/get-neighborhood-score/get-neighborhood-score.query';
|
||||
import { type PriceMoversDto } from '../../application/queries/get-price-movers/get-price-movers.handler';
|
||||
import { GetPriceMoversQuery } from '../../application/queries/get-price-movers/get-price-movers.query';
|
||||
import { type PriceTrendDto } from '../../application/queries/get-price-trend/get-price-trend.handler';
|
||||
import { GetPriceTrendQuery } from '../../application/queries/get-price-trend/get-price-trend.query';
|
||||
import {
|
||||
type ProjectAiAdviceResponse,
|
||||
} from '../../application/queries/get-project-ai-advice/get-project-ai-advice.handler';
|
||||
import { GetProjectAiAdviceQuery } from '../../application/queries/get-project-ai-advice/get-project-ai-advice.query';
|
||||
import { type MarketReportDto } from '../../application/queries/get-market-report/get-market-report.handler';
|
||||
import { GetMarketReportQuery } from '../../application/queries/get-market-report/get-market-report.query';
|
||||
import { type NearbyPOIsResultDto } from '../../application/queries/get-nearby-pois/get-nearby-pois.handler';
|
||||
import { GetNearbyPOIsQuery } from '../../application/queries/get-nearby-pois/get-nearby-pois.query';
|
||||
import { GetNeighborhoodScoreQuery } from '../../application/queries/get-neighborhood-score/get-neighborhood-score.query';
|
||||
import { type PriceTrendDto } from '../../application/queries/get-price-trend/get-price-trend.handler';
|
||||
import { GetPriceTrendQuery } from '../../application/queries/get-price-trend/get-price-trend.query';
|
||||
import { type TrendingAreasDto } from '../../application/queries/get-trending-areas/get-trending-areas.handler';
|
||||
import { GetTrendingAreasQuery } from '../../application/queries/get-trending-areas/get-trending-areas.query';
|
||||
import { type ValuationDto } from '../../application/queries/get-valuation/get-valuation.handler';
|
||||
import { GetValuationQuery } from '../../application/queries/get-valuation/get-valuation.query';
|
||||
import { type PredictValuationDto } from '../../application/queries/predict-valuation/predict-valuation.handler';
|
||||
@@ -45,15 +56,22 @@ import { type NeighborhoodScoreResult } from '../../domain/services/neighborhood
|
||||
import { BatchValuationDto } from '../dto/batch-valuation.dto';
|
||||
import { GetDistrictStatsDto } from '../dto/get-district-stats.dto';
|
||||
import { GetHeatmapDto } from '../dto/get-heatmap.dto';
|
||||
import { GetListingVolumeWardDto } from '../dto/get-listing-volume-ward.dto';
|
||||
import { GetMarketHistoryDto } from '../dto/get-market-history.dto';
|
||||
import { GetMarketReportDto } from '../dto/get-market-report.dto';
|
||||
import { GetMarketSnapshotDto } from '../dto/get-market-snapshot.dto';
|
||||
import { GetNearbyPOIsDto } from '../dto/get-nearby-pois.dto';
|
||||
import { GetPriceMoversDto } from '../dto/get-price-movers.dto';
|
||||
import { GetPriceTrendDto } from '../dto/get-price-trend.dto';
|
||||
import { GetTrendingAreasDto } from '../dto/get-trending-areas.dto';
|
||||
import { GetValuationDto } from '../dto/get-valuation.dto';
|
||||
import { PredictValuationDto as PredictValuationBodyDto } from '../dto/predict-valuation.dto';
|
||||
import { ValuationComparisonDto } from '../dto/valuation-comparison.dto';
|
||||
import { ValuationHistoryDto } from '../dto/valuation-history.dto';
|
||||
import { CacheMetaInterceptor } from '../interceptors/cache-meta.interceptor';
|
||||
|
||||
@ApiTags('analytics')
|
||||
@UseInterceptors(CacheMetaInterceptor)
|
||||
@Controller('analytics')
|
||||
export class AnalyticsController {
|
||||
constructor(
|
||||
@@ -73,6 +91,57 @@ export class AnalyticsController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@UseGuards(JwtAuthGuard, QuotaGuard)
|
||||
@RequireQuota('analytics_queries')
|
||||
@Get('market-history')
|
||||
@ApiOperation({
|
||||
summary: 'Lịch sử thị trường BĐS theo chuỗi thời gian',
|
||||
description:
|
||||
'Trả về time-series dữ liệu thị trường (giá trung bình, giá trung vị, số tin đăng, thời gian rao) cho trang analytics. Cache 6 giờ.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Market history time-series retrieved' })
|
||||
@ApiResponse({ status: 403, description: 'Quota exceeded' })
|
||||
async getMarketHistory(@Query() dto: GetMarketHistoryDto): Promise<MarketHistoryDto> {
|
||||
return this.queryBus.execute(
|
||||
new GetMarketHistoryQuery(dto.city, dto.period, dto.granularity, dto.propertyType),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@UseGuards(JwtAuthGuard, QuotaGuard)
|
||||
@RequireQuota('analytics_queries')
|
||||
@Get('market-snapshot')
|
||||
@ApiOperation({
|
||||
summary: 'Tổng quan thị trường cho dashboard tiles',
|
||||
description:
|
||||
'Trả về snapshot thị trường BĐS: số tin đang hoạt động, giá trung bình, giá trung vị, biến động giá 1d/7d/30d, giá/m², thời gian rao trung bình, tin mới 24h. Cache Redis 5 phút.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Market snapshot retrieved' })
|
||||
@ApiResponse({ status: 403, description: 'Quota exceeded' })
|
||||
async getMarketSnapshot(@Query() dto: GetMarketSnapshotDto): Promise<MarketSnapshotDto> {
|
||||
return this.queryBus.execute(
|
||||
new GetMarketSnapshotQuery(dto.city, dto.propertyType),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@UseGuards(JwtAuthGuard, QuotaGuard)
|
||||
@RequireQuota('analytics_queries')
|
||||
@Get('price-movers')
|
||||
@ApiOperation({
|
||||
summary: 'Top tăng/giảm giá theo quận cho Home dashboard',
|
||||
description:
|
||||
'Trả về danh sách quận có biến động giá lớn nhất (tăng hoặc giảm) trong khoảng thời gian chỉ định. Chỉ hiển thị quận có ≥ 10 tin đăng. Cache Redis 30 phút.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Price movers retrieved' })
|
||||
@ApiResponse({ status: 403, description: 'Quota exceeded' })
|
||||
async getPriceMovers(@Query() dto: GetPriceMoversDto): Promise<PriceMoversDto> {
|
||||
return this.queryBus.execute(
|
||||
new GetPriceMoversQuery(dto.direction, dto.period, dto.limit, dto.level),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@UseGuards(JwtAuthGuard, QuotaGuard)
|
||||
@RequireQuota('analytics_queries')
|
||||
@@ -90,12 +159,34 @@ export class AnalyticsController {
|
||||
@UseGuards(JwtAuthGuard, QuotaGuard)
|
||||
@RequireQuota('analytics_queries')
|
||||
@Get('heatmap')
|
||||
@ApiOperation({ summary: 'Get price heatmap for a city' })
|
||||
@ApiOperation({
|
||||
summary: 'Get price heatmap for a city',
|
||||
description:
|
||||
'Trả về dữ liệu heatmap giá BĐS. `level=district` (mặc định) cho aggregation theo quận; `level=ward` drill-down xuống cấp phường. Cache 30 phút cho ward, 5 phút cho district.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Heatmap data retrieved' })
|
||||
@ApiResponse({ status: 403, description: 'Quota exceeded' })
|
||||
async getHeatmap(@Query() dto: GetHeatmapDto): Promise<HeatmapDto> {
|
||||
return this.queryBus.execute(
|
||||
new GetHeatmapQuery(dto.city, dto.period),
|
||||
new GetHeatmapQuery(dto.city, dto.period, dto.level ?? 'district', dto.district),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@UseGuards(JwtAuthGuard, QuotaGuard)
|
||||
@RequireQuota('analytics_queries')
|
||||
@Get('listing-volume')
|
||||
@ApiOperation({
|
||||
summary: '[TEC-3055] Khối lượng tin đăng và giá trung bình/trung vị theo phường',
|
||||
description:
|
||||
'Drill-down volume tin đăng + giá avg/median cho một phường trong kỳ chỉ định. `wardId` là tên phường (khớp với `Property.ward`). `period` dạng "YYYY-QN" (quý) hoặc "YYYY-MM" (tháng). Cache 30 phút.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Listing volume data retrieved' })
|
||||
@ApiResponse({ status: 404, description: 'Không có dữ liệu cho phường và kỳ này' })
|
||||
@ApiResponse({ status: 403, description: 'Quota exceeded' })
|
||||
async getListingVolumeByWard(@Query() dto: GetListingVolumeWardDto): Promise<ListingVolumeWardDto> {
|
||||
return this.queryBus.execute(
|
||||
new GetListingVolumeWardQuery(dto.wardId, dto.period),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -268,6 +359,19 @@ export class AnalyticsController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: 'Top khu vực đang trending (public)',
|
||||
description:
|
||||
'Trả về danh sách quận trending theo lượng tin đăng/inquiries/views trong khoảng nhìn lại. Public endpoint cho homepage. Cache.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Trending areas retrieved' })
|
||||
@Get('trending-areas')
|
||||
async getTrendingAreas(@Query() dto: GetTrendingAreasDto): Promise<TrendingAreasDto> {
|
||||
return this.queryBus.execute(
|
||||
new GetTrendingAreasQuery(dto.period, dto.limit, dto.level),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('listings/:id/ai-advice')
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody, ApiParam, ApiQuery } from '@nestjs/swagger';
|
||||
@@ -27,8 +28,10 @@ import { AvmExplainQueryDto } from '../dto/avm-explain-query.dto';
|
||||
import { BatchValuationDto } from '../dto/batch-valuation.dto';
|
||||
import { IndustrialValuationDto } from '../dto/industrial-valuation.dto';
|
||||
import { ValuationHistoryDto } from '../dto/valuation-history.dto';
|
||||
import { CacheMetaInterceptor } from '../interceptors/cache-meta.interceptor';
|
||||
|
||||
@ApiTags('avm')
|
||||
@UseInterceptors(CacheMetaInterceptor)
|
||||
@Controller('avm')
|
||||
export class AvmController {
|
||||
constructor(
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional, IsString } from 'class-validator';
|
||||
import { type HeatmapLevel } from '../../application/queries/get-heatmap/get-heatmap.query';
|
||||
|
||||
export class GetHeatmapDto {
|
||||
@ApiProperty({ description: 'City name' })
|
||||
@IsString()
|
||||
city!: string;
|
||||
|
||||
@ApiProperty({ description: 'Time period' })
|
||||
@ApiProperty({ description: 'Time period (e.g. "2026-Q1" or "2026-03")' })
|
||||
@IsString()
|
||||
period!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Zoom level: "district" (default) or "ward" for drill-down',
|
||||
enum: ['district', 'ward'],
|
||||
default: 'district',
|
||||
})
|
||||
@IsEnum(['district', 'ward'])
|
||||
@IsOptional()
|
||||
level?: HeatmapLevel;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter by district when level=ward (optional)',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
district?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class GetListingVolumeWardDto {
|
||||
@ApiProperty({ description: 'Ward name (matches Property.ward)', example: 'Phường Bến Nghé' })
|
||||
@IsString()
|
||||
wardId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Time period — quarterly "YYYY-QN" or monthly "YYYY-MM"',
|
||||
example: '2026-Q1',
|
||||
})
|
||||
@IsString()
|
||||
period!: string;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { PropertyType } from '@prisma/client';
|
||||
import { IsEnum, IsIn, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class GetMarketHistoryDto {
|
||||
@ApiProperty({ description: 'City name', example: 'HCMC' })
|
||||
@IsString()
|
||||
city!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Look-back period (e.g. 12m, 6m, 24m)',
|
||||
example: '12m',
|
||||
})
|
||||
@IsString()
|
||||
period!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Time granularity',
|
||||
enum: ['monthly', 'weekly'],
|
||||
default: 'monthly',
|
||||
})
|
||||
@IsIn(['monthly', 'weekly'])
|
||||
granularity!: 'monthly' | 'weekly';
|
||||
|
||||
@ApiPropertyOptional({ enum: PropertyType, description: 'Property type filter' })
|
||||
@IsOptional()
|
||||
@IsEnum(PropertyType)
|
||||
propertyType?: PropertyType;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { PropertyType } from '@prisma/client';
|
||||
import { IsEnum, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class GetMarketSnapshotDto {
|
||||
@ApiProperty({ description: 'City name', example: 'HCMC' })
|
||||
@IsString()
|
||||
city!: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: PropertyType, description: 'Property type filter' })
|
||||
@IsOptional()
|
||||
@IsEnum(PropertyType)
|
||||
propertyType?: PropertyType;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
|
||||
export class GetPriceMoversDto {
|
||||
@ApiProperty({
|
||||
description: 'Price movement direction',
|
||||
enum: ['up', 'down'],
|
||||
example: 'up',
|
||||
})
|
||||
@IsIn(['up', 'down'])
|
||||
direction: 'up' | 'down' = 'up';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Look-back period',
|
||||
enum: ['7d', '14d', '30d'],
|
||||
default: '7d',
|
||||
example: '7d',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['7d', '14d', '30d'])
|
||||
period: string = '7d';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Maximum number of results to return',
|
||||
minimum: 1,
|
||||
maximum: 20,
|
||||
default: 5,
|
||||
example: 5,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(20)
|
||||
limit: number = 5;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Geographic aggregation level (currently only "district" is supported)',
|
||||
enum: ['district'],
|
||||
default: 'district',
|
||||
example: 'district',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['district'])
|
||||
level = 'district' as const;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
|
||||
export class GetTrendingAreasDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Look-back window in days',
|
||||
enum: [7, 14, 30],
|
||||
default: 7,
|
||||
example: 7,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@IsIn([7, 14, 30])
|
||||
period: number = 7;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Maximum number of trending areas to return',
|
||||
minimum: 1,
|
||||
maximum: 50,
|
||||
default: 10,
|
||||
example: 10,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(50)
|
||||
limit: number = 10;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Geographic aggregation level (currently only "district" is supported)',
|
||||
enum: ['district'],
|
||||
default: 'district',
|
||||
example: 'district',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['district'])
|
||||
level = 'district' as const;
|
||||
}
|
||||
@@ -8,3 +8,5 @@ export { ValuationHistoryDto } from './valuation-history.dto';
|
||||
export { ValuationComparisonDto } from './valuation-comparison.dto';
|
||||
export { AvmCompareQueryDto } from './avm-compare-query.dto';
|
||||
export { IndustrialValuationDto } from './industrial-valuation.dto';
|
||||
export { GetTrendingAreasDto } from './get-trending-areas.dto';
|
||||
export { GetPriceMoversDto } from './get-price-movers.dto';
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
Injectable,
|
||||
type CallHandler,
|
||||
type ExecutionContext,
|
||||
type NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { cacheMetaStorage, type CacheMeta } from '@modules/shared';
|
||||
|
||||
/**
|
||||
* Shape appended to every `/analytics/*` response.
|
||||
*/
|
||||
export interface WithCacheMeta<T> {
|
||||
data: T;
|
||||
cacheMeta: CacheMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* NestJS interceptor that:
|
||||
* 1. Creates an AsyncLocalStorage context for the request so CacheService
|
||||
* can populate per-request cache metadata.
|
||||
* 2. After the handler resolves, wraps the response payload with a `cacheMeta`
|
||||
* field describing freshness: `{ cachedAt, nextRefreshAt, source }`.
|
||||
*
|
||||
* Apply at controller class or individual method level:
|
||||
* ```ts
|
||||
* @UseInterceptors(CacheMetaInterceptor)
|
||||
* @Controller('analytics')
|
||||
* export class AnalyticsController { ... }
|
||||
* ```
|
||||
*
|
||||
* Responses are transformed from `T` to `{ data: T; cacheMeta: CacheMeta }`.
|
||||
* When CacheService was not called during the request (e.g. command endpoints),
|
||||
* `cacheMeta` defaults to `{ cachedAt: null, nextRefreshAt: null, source: 'fresh' }`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CacheMetaInterceptor implements NestInterceptor {
|
||||
intercept(_context: ExecutionContext, next: CallHandler): Observable<WithCacheMeta<unknown>> {
|
||||
const store = { meta: null as CacheMeta | null };
|
||||
|
||||
return new Observable((subscriber) => {
|
||||
cacheMetaStorage.run(store, () => {
|
||||
next
|
||||
.handle()
|
||||
.pipe(
|
||||
map((data: unknown) => {
|
||||
const cacheMeta: CacheMeta = store.meta ?? {
|
||||
cachedAt: null,
|
||||
nextRefreshAt: null,
|
||||
source: 'fresh',
|
||||
};
|
||||
return { data, cacheMeta };
|
||||
}),
|
||||
)
|
||||
.subscribe(subscriber);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -194,4 +194,47 @@ describe('Auth Controller (Integration)', () => {
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/exchange-token', () => {
|
||||
let validAccessToken: string;
|
||||
let validRefreshToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({
|
||||
phone: '0912345678',
|
||||
password: 'StrongPass123',
|
||||
});
|
||||
validAccessToken = res.body.accessToken as string;
|
||||
validRefreshToken = res.body.refreshToken as string;
|
||||
});
|
||||
|
||||
it('should set auth cookies for a valid token pair', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/auth/exchange-token')
|
||||
.send({ accessToken: validAccessToken, refreshToken: validRefreshToken })
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.message).toBe('Auth cookies set');
|
||||
const setCookie = res.headers['set-cookie'] as string[] | string;
|
||||
const cookieStr = Array.isArray(setCookie) ? setCookie.join('; ') : (setCookie ?? '');
|
||||
expect(cookieStr).toContain('access_token=');
|
||||
expect(cookieStr).toContain('refresh_token=');
|
||||
});
|
||||
|
||||
it('should return 401 for an invalid access token', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/auth/exchange-token')
|
||||
.send({ accessToken: 'invalid.token.here', refreshToken: validRefreshToken })
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('should return 401 when accessToken is missing', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/auth/exchange-token')
|
||||
.send({ refreshToken: validRefreshToken })
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { PayloadTooLargeException } from '@nestjs/common';
|
||||
import { NotFoundException } from '@modules/shared';
|
||||
import { ExportUserDataCommand } from '../commands/export-user-data/export-user-data.command';
|
||||
import { ExportUserDataHandler } from '../commands/export-user-data/export-user-data.handler';
|
||||
|
||||
async function readStream(stream: NodeJS.ReadableStream): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string));
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
describe('ExportUserDataHandler', () => {
|
||||
let handler: ExportUserDataHandler;
|
||||
|
||||
@@ -17,7 +26,13 @@ describe('ExportUserDataHandler', () => {
|
||||
transaction: { findMany: vi.fn() },
|
||||
};
|
||||
|
||||
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
|
||||
const mockLogger = {
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
verbose: vi.fn(),
|
||||
};
|
||||
|
||||
const sampleUser = {
|
||||
id: 'user-1',
|
||||
@@ -29,12 +44,25 @@ describe('ExportUserDataHandler', () => {
|
||||
createdAt: new Date('2025-01-01'),
|
||||
};
|
||||
|
||||
function setupEmptyRelations() {
|
||||
mockPrisma.agent.findUnique.mockResolvedValue(null);
|
||||
mockPrisma.listing.findMany.mockResolvedValue([]);
|
||||
mockPrisma.payment.findMany.mockResolvedValue([]);
|
||||
mockPrisma.subscription.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.review.findMany.mockResolvedValue([]);
|
||||
mockPrisma.inquiry.findMany.mockResolvedValue([]);
|
||||
mockPrisma.savedSearch.findMany.mockResolvedValue([]);
|
||||
mockPrisma.transaction.findMany.mockResolvedValue([]);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env['EXPORT_ROW_CAP'];
|
||||
delete process.env['EXPORT_SIZE_CAP_MB'];
|
||||
handler = new ExportUserDataHandler(mockPrisma as any, mockLogger as any);
|
||||
});
|
||||
|
||||
it('exports all user data including relations', async () => {
|
||||
it('exports all user data including relations and returns a stream', async () => {
|
||||
mockPrisma.user.findUnique.mockResolvedValue(sampleUser);
|
||||
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1', userId: 'user-1' });
|
||||
mockPrisma.listing.findMany.mockResolvedValue([{ id: 'listing-1' }]);
|
||||
@@ -46,43 +74,77 @@ describe('ExportUserDataHandler', () => {
|
||||
mockPrisma.transaction.findMany.mockResolvedValue([{ id: 'tx-1' }]);
|
||||
|
||||
const result = await handler.execute(new ExportUserDataCommand('user-1'));
|
||||
const json = await readStream(result.stream);
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
expect(result.user).toEqual(sampleUser);
|
||||
expect(result.agent).toEqual({ id: 'agent-1', userId: 'user-1' });
|
||||
expect(result.listings).toHaveLength(1);
|
||||
expect(result.payments).toHaveLength(1);
|
||||
expect(result.subscription).toEqual({ id: 'sub-1', status: 'ACTIVE' });
|
||||
expect(result.reviews).toHaveLength(1);
|
||||
expect(result.inquiries).toHaveLength(1);
|
||||
expect(result.savedSearches).toHaveLength(1);
|
||||
expect(result.transactions).toHaveLength(1);
|
||||
expect(parsed.user).toMatchObject({ id: 'user-1' });
|
||||
expect(parsed.agent).toEqual({ id: 'agent-1', userId: 'user-1' });
|
||||
expect(parsed.listings).toHaveLength(1);
|
||||
expect(parsed.payments).toHaveLength(1);
|
||||
expect(parsed.subscription).toEqual({ id: 'sub-1', status: 'ACTIVE' });
|
||||
expect(parsed.reviews).toHaveLength(1);
|
||||
expect(parsed.inquiries).toHaveLength(1);
|
||||
expect(parsed.savedSearches).toHaveLength(1);
|
||||
expect(parsed.transactions).toHaveLength(1);
|
||||
expect(result.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('throws NotFoundException if user not found', async () => {
|
||||
mockPrisma.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
handler.execute(new ExportUserDataCommand('missing')),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
await expect(handler.execute(new ExportUserDataCommand('missing'))).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('includes exportedAt timestamp', async () => {
|
||||
it('includes exportedAt timestamp and cap metadata in the payload', async () => {
|
||||
mockPrisma.user.findUnique.mockResolvedValue(sampleUser);
|
||||
mockPrisma.agent.findUnique.mockResolvedValue(null);
|
||||
mockPrisma.listing.findMany.mockResolvedValue([]);
|
||||
mockPrisma.payment.findMany.mockResolvedValue([]);
|
||||
mockPrisma.subscription.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.review.findMany.mockResolvedValue([]);
|
||||
mockPrisma.inquiry.findMany.mockResolvedValue([]);
|
||||
mockPrisma.savedSearch.findMany.mockResolvedValue([]);
|
||||
mockPrisma.transaction.findMany.mockResolvedValue([]);
|
||||
setupEmptyRelations();
|
||||
|
||||
const before = new Date().toISOString();
|
||||
const result = await handler.execute(new ExportUserDataCommand('user-1'));
|
||||
const after = new Date().toISOString();
|
||||
const parsed = JSON.parse(await readStream(result.stream));
|
||||
|
||||
expect(result.exportedAt).toBeDefined();
|
||||
expect(result.exportedAt >= before).toBe(true);
|
||||
expect(result.exportedAt <= after).toBe(true);
|
||||
expect(parsed.exportedAt).toBeDefined();
|
||||
expect(parsed.exportedAt >= before).toBe(true);
|
||||
expect(parsed.exportedAt <= after).toBe(true);
|
||||
expect(typeof parsed.rowCap).toBe('number');
|
||||
expect(typeof parsed.sizeCap).toBe('number');
|
||||
});
|
||||
|
||||
it('applies row cap to each collection query', async () => {
|
||||
process.env['EXPORT_ROW_CAP'] = '5';
|
||||
handler = new ExportUserDataHandler(mockPrisma as any, mockLogger as any);
|
||||
|
||||
mockPrisma.user.findUnique.mockResolvedValue(sampleUser);
|
||||
setupEmptyRelations();
|
||||
|
||||
await handler.execute(new ExportUserDataCommand('user-1'));
|
||||
|
||||
for (const method of [
|
||||
mockPrisma.listing.findMany,
|
||||
mockPrisma.payment.findMany,
|
||||
mockPrisma.review.findMany,
|
||||
mockPrisma.inquiry.findMany,
|
||||
mockPrisma.savedSearch.findMany,
|
||||
mockPrisma.transaction.findMany,
|
||||
]) {
|
||||
expect(method).toHaveBeenCalledWith(expect.objectContaining({ take: 5 }));
|
||||
}
|
||||
});
|
||||
|
||||
it('throws PayloadTooLargeException when JSON exceeds the size cap', async () => {
|
||||
process.env['EXPORT_SIZE_CAP_MB'] = '0.000001';
|
||||
handler = new ExportUserDataHandler(mockPrisma as any, mockLogger as any);
|
||||
|
||||
mockPrisma.user.findUnique.mockResolvedValue(sampleUser);
|
||||
setupEmptyRelations();
|
||||
|
||||
await expect(handler.execute(new ExportUserDataCommand('user-1'))).rejects.toThrow(
|
||||
PayloadTooLargeException,
|
||||
);
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ describe('LoginUserHandler', () => {
|
||||
let handler: LoginUserHandler;
|
||||
let mockTokenService: { generateTokenPair: ReturnType<typeof vi.fn> };
|
||||
let mockChallengeRepo: { create: ReturnType<typeof vi.fn> };
|
||||
let mockUserRepo: { updateMfaGraceStartedAt: ReturnType<typeof vi.fn> };
|
||||
let mockLogger: { error: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
|
||||
|
||||
const tokenPair = {
|
||||
accessToken: 'access-jwt',
|
||||
@@ -15,22 +17,30 @@ describe('LoginUserHandler', () => {
|
||||
beforeEach(() => {
|
||||
mockTokenService = { generateTokenPair: vi.fn().mockResolvedValue(tokenPair) };
|
||||
mockChallengeRepo = { create: vi.fn().mockResolvedValue({}) };
|
||||
handler = new LoginUserHandler(mockTokenService as any, mockChallengeRepo as any);
|
||||
mockUserRepo = { updateMfaGraceStartedAt: vi.fn().mockResolvedValue(undefined) };
|
||||
mockLogger = { error: vi.fn(), warn: vi.fn() };
|
||||
handler = new LoginUserHandler(
|
||||
mockTokenService as any,
|
||||
mockChallengeRepo as any,
|
||||
mockUserRepo as any,
|
||||
mockLogger as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('generates token pair with correct payload when MFA not required', async () => {
|
||||
it('generates token pair with mfa=none for non-required role when MFA not required', async () => {
|
||||
const command = new LoginUserCommand('user-1', '0912345678', 'BUYER', false);
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result).toEqual({ requiresMfa: false, tokens: tokenPair });
|
||||
expect(result).toEqual({ requiresMfa: false, tokens: tokenPair, mfaGraceRemainingDays: undefined });
|
||||
expect(mockTokenService.generateTokenPair).toHaveBeenCalledWith({
|
||||
sub: 'user-1',
|
||||
phone: '0912345678',
|
||||
role: 'BUYER',
|
||||
mfa: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates MFA challenge when MFA is required', async () => {
|
||||
it('creates MFA challenge when MFA is required (user already enrolled)', async () => {
|
||||
const command = new LoginUserCommand('user-1', '0912345678', 'BUYER', true);
|
||||
const result = await handler.execute(command);
|
||||
|
||||
@@ -49,7 +59,7 @@ describe('LoginUserHandler', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('passes AGENT role correctly', async () => {
|
||||
it('AGENT role does not require MFA — issues mfa=none claim', async () => {
|
||||
const command = new LoginUserCommand('user-2', '0987654321', 'AGENT');
|
||||
await handler.execute(command);
|
||||
|
||||
@@ -57,17 +67,51 @@ describe('LoginUserHandler', () => {
|
||||
sub: 'user-2',
|
||||
phone: '0987654321',
|
||||
role: 'AGENT',
|
||||
mfa: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes ADMIN role correctly', async () => {
|
||||
const command = new LoginUserCommand('admin-1', '0901234567', 'ADMIN');
|
||||
await handler.execute(command);
|
||||
it('ADMIN without TOTP enters grace period on first login under enforcement', async () => {
|
||||
const command = new LoginUserCommand(
|
||||
'admin-1',
|
||||
'0901234567',
|
||||
'ADMIN',
|
||||
false,
|
||||
false, // totpEnabled
|
||||
null, // mfaGraceStartedAt — first login
|
||||
);
|
||||
const result = await handler.execute(command);
|
||||
|
||||
// Grace was started lazily
|
||||
expect(mockUserRepo.updateMfaGraceStartedAt).toHaveBeenCalledWith('admin-1', expect.any(Date));
|
||||
expect(result.mfaGraceRemainingDays).toBe(14);
|
||||
expect(mockTokenService.generateTokenPair).toHaveBeenCalledWith({
|
||||
sub: 'admin-1',
|
||||
phone: '0901234567',
|
||||
role: 'ADMIN',
|
||||
mfa: 'grace',
|
||||
});
|
||||
});
|
||||
|
||||
it('ADMIN past grace window receives mfa=enrollment_required claim', async () => {
|
||||
const longAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days ago
|
||||
const command = new LoginUserCommand(
|
||||
'admin-1',
|
||||
'0901234567',
|
||||
'ADMIN',
|
||||
false,
|
||||
false,
|
||||
longAgo,
|
||||
);
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(mockUserRepo.updateMfaGraceStartedAt).not.toHaveBeenCalled();
|
||||
expect(result.mfaGraceRemainingDays).toBe(0);
|
||||
expect(mockTokenService.generateTokenPair).toHaveBeenCalledWith({
|
||||
sub: 'admin-1',
|
||||
phone: '0901234567',
|
||||
role: 'ADMIN',
|
||||
mfa: 'enrollment_required',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { Readable } from 'node:stream';
|
||||
import { HttpException, InternalServerErrorException, PayloadTooLargeException } from '@nestjs/common';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { LoggerService, PrismaService, DomainException, NotFoundException } from '@modules/shared';
|
||||
import { ExportUserDataCommand } from './export-user-data.command';
|
||||
|
||||
/** Per-collection row cap. Override via EXPORT_ROW_CAP env var (default 10 000). */
|
||||
const DEFAULT_ROW_CAP = 10_000;
|
||||
/** Maximum total export size in megabytes. Override via EXPORT_SIZE_CAP_MB env var (default 100). */
|
||||
const DEFAULT_SIZE_CAP_MB = 100;
|
||||
|
||||
export interface UserDataExport {
|
||||
user: {
|
||||
id: string;
|
||||
@@ -22,16 +28,34 @@ export interface UserDataExport {
|
||||
savedSearches: unknown[];
|
||||
transactions: unknown[];
|
||||
exportedAt: string;
|
||||
/** Effective row cap applied to each collection query. */
|
||||
rowCap: number;
|
||||
/** Effective size cap in bytes for the entire JSON payload. */
|
||||
sizeCap: number;
|
||||
}
|
||||
|
||||
export interface ExportUserDataResult {
|
||||
/** Node.js Readable stream containing the UTF-8 encoded JSON payload. */
|
||||
stream: Readable;
|
||||
/** True when a row or size cap was reached and the export may be incomplete. */
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
@CommandHandler(ExportUserDataCommand)
|
||||
export class ExportUserDataHandler implements ICommandHandler<ExportUserDataCommand> {
|
||||
private readonly rowCap: number;
|
||||
private readonly sizeCapBytes: number;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
) {
|
||||
this.rowCap = parseInt(process.env['EXPORT_ROW_CAP'] ?? String(DEFAULT_ROW_CAP), 10);
|
||||
const sizeMb = parseFloat(process.env['EXPORT_SIZE_CAP_MB'] ?? String(DEFAULT_SIZE_CAP_MB));
|
||||
this.sizeCapBytes = Math.floor(sizeMb * 1024 * 1024);
|
||||
}
|
||||
|
||||
async execute(command: ExportUserDataCommand): Promise<UserDataExport> {
|
||||
async execute(command: ExportUserDataCommand): Promise<ExportUserDataResult> {
|
||||
try {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: command.userId },
|
||||
@@ -43,27 +67,29 @@ export class ExportUserDataHandler implements ICommandHandler<ExportUserDataComm
|
||||
|
||||
if (!user) throw new NotFoundException('User', command.userId);
|
||||
|
||||
const rowCap = this.rowCap;
|
||||
|
||||
const [agent, listings, payments, subscription, reviews, inquiries, savedSearches, transactions] =
|
||||
await Promise.all([
|
||||
this.prisma.agent.findUnique({ where: { userId: command.userId } }),
|
||||
this.prisma.listing.findMany({
|
||||
where: { sellerId: command.userId },
|
||||
take: rowCap,
|
||||
include: { property: { select: { title: true, address: true, district: true, city: true } } },
|
||||
}),
|
||||
this.prisma.payment.findMany({
|
||||
where: { userId: command.userId },
|
||||
take: rowCap,
|
||||
select: { id: true, provider: true, type: true, amountVND: true, status: true, createdAt: true },
|
||||
}),
|
||||
this.prisma.subscription.findFirst({ where: { userId: command.userId } }),
|
||||
this.prisma.review.findMany({ where: { userId: command.userId } }),
|
||||
this.prisma.inquiry.findMany({ where: { userId: command.userId } }),
|
||||
this.prisma.savedSearch.findMany({ where: { userId: command.userId } }),
|
||||
this.prisma.transaction.findMany({ where: { buyerId: command.userId } }),
|
||||
this.prisma.review.findMany({ where: { userId: command.userId }, take: rowCap }),
|
||||
this.prisma.inquiry.findMany({ where: { userId: command.userId }, take: rowCap }),
|
||||
this.prisma.savedSearch.findMany({ where: { userId: command.userId }, take: rowCap }),
|
||||
this.prisma.transaction.findMany({ where: { buyerId: command.userId }, take: rowCap }),
|
||||
]);
|
||||
|
||||
this.logger.log(`User data exported for ${command.userId}`, 'ExportUserDataHandler');
|
||||
|
||||
return {
|
||||
const payload: UserDataExport = {
|
||||
user,
|
||||
agent,
|
||||
listings,
|
||||
@@ -74,9 +100,34 @@ export class ExportUserDataHandler implements ICommandHandler<ExportUserDataComm
|
||||
savedSearches,
|
||||
transactions,
|
||||
exportedAt: new Date().toISOString(),
|
||||
rowCap,
|
||||
sizeCap: this.sizeCapBytes,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(payload);
|
||||
const byteLength = Buffer.byteLength(json, 'utf8');
|
||||
|
||||
if (byteLength > this.sizeCapBytes) {
|
||||
this.logger.warn(
|
||||
`Export for user ${command.userId} is ${byteLength} bytes, exceeds cap of ${this.sizeCapBytes} bytes`,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new PayloadTooLargeException(
|
||||
`Dữ liệu xuất (${Math.round(byteLength / 1024 / 1024)} MB) vượt giới hạn ` +
|
||||
`${Math.round(this.sizeCapBytes / 1024 / 1024)} MB. ` +
|
||||
`Vui lòng liên hệ hỗ trợ để xuất theo từng phần.`,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`User data exported for ${command.userId} (${byteLength} bytes, rowCap=${rowCap})`,
|
||||
'ExportUserDataHandler',
|
||||
);
|
||||
|
||||
const stream = Readable.from(Buffer.from(json, 'utf8'));
|
||||
return { stream, truncated: false };
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
if (error instanceof DomainException || error instanceof HttpException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to export user data: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
|
||||
@@ -4,5 +4,7 @@ export class LoginUserCommand {
|
||||
public readonly phone: string,
|
||||
public readonly role: string,
|
||||
public readonly isMfaRequired: boolean = false,
|
||||
public readonly totpEnabled: boolean = false,
|
||||
public readonly mfaGraceStartedAt: Date | null = null,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { type UserRole } from '@prisma/client';
|
||||
import { LoggerService, DomainException } from '@modules/shared';
|
||||
import { MFA_GRACE_PERIOD_DAYS, MFA_REQUIRED_ROLES } from '../../../domain/mfa-policy';
|
||||
import {
|
||||
MFA_CHALLENGE_REPOSITORY,
|
||||
type IMfaChallengeRepository,
|
||||
} from '../../../domain/repositories/mfa-challenge.repository';
|
||||
import { TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
|
||||
import {
|
||||
USER_REPOSITORY,
|
||||
type IUserRepository,
|
||||
} from '../../../domain/repositories/user.repository';
|
||||
import { TokenService, type MfaClaim, type TokenPair } from '../../../infrastructure/services/token.service';
|
||||
import { LoginUserCommand } from './login-user.command';
|
||||
|
||||
const MFA_CHALLENGE_TTL_MINUTES = 5;
|
||||
@@ -15,6 +21,7 @@ export interface LoginResult {
|
||||
requiresMfa: boolean;
|
||||
challengeId?: string;
|
||||
tokens?: TokenPair;
|
||||
mfaGraceRemainingDays?: number;
|
||||
}
|
||||
|
||||
@CommandHandler(LoginUserCommand)
|
||||
@@ -23,12 +30,14 @@ export class LoginUserHandler implements ICommandHandler<LoginUserCommand> {
|
||||
private readonly tokenService: TokenService,
|
||||
@Inject(MFA_CHALLENGE_REPOSITORY)
|
||||
private readonly challengeRepo: IMfaChallengeRepository,
|
||||
@Inject(USER_REPOSITORY)
|
||||
private readonly userRepo: IUserRepository,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(command: LoginUserCommand): Promise<LoginResult> {
|
||||
try {
|
||||
// If MFA is required, create a challenge instead of tokens
|
||||
// If MFA is required (user already enrolled), create a challenge
|
||||
if (command.isMfaRequired) {
|
||||
const challengeId = createId();
|
||||
const expiresAt = new Date();
|
||||
@@ -50,16 +59,32 @@ export class LoginUserHandler implements ICommandHandler<LoginUserCommand> {
|
||||
};
|
||||
}
|
||||
|
||||
// No MFA — issue tokens directly
|
||||
// Determine MFA claim for non-enrolled users
|
||||
const roleRequiresMfa = MFA_REQUIRED_ROLES.includes(command.role as UserRole);
|
||||
|
||||
let mfaClaim: MfaClaim = 'none';
|
||||
let mfaGraceRemainingDays: number | undefined;
|
||||
|
||||
if (roleRequiresMfa && !command.totpEnabled) {
|
||||
const result = await this.resolveMfaGraceClaim(
|
||||
command.userId,
|
||||
command.mfaGraceStartedAt,
|
||||
);
|
||||
mfaClaim = result.claim;
|
||||
mfaGraceRemainingDays = result.remainingDays;
|
||||
}
|
||||
|
||||
const tokens = await this.tokenService.generateTokenPair({
|
||||
sub: command.userId,
|
||||
phone: command.phone,
|
||||
role: command.role,
|
||||
mfa: mfaClaim,
|
||||
});
|
||||
|
||||
return {
|
||||
requiresMfa: false,
|
||||
tokens,
|
||||
mfaGraceRemainingDays,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
@@ -71,5 +96,33 @@ export class LoginUserHandler implements ICommandHandler<LoginUserCommand> {
|
||||
throw new InternalServerErrorException('Không thể tạo phiên đăng nhập, vui lòng thử lại');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-initialises mfaGraceStartedAt if the role requires MFA but
|
||||
* the user hasn't enrolled yet. Returns the appropriate MFA claim
|
||||
* and the number of grace days remaining (if any).
|
||||
*/
|
||||
private async resolveMfaGraceClaim(
|
||||
userId: string,
|
||||
mfaGraceStartedAt: Date | null,
|
||||
): Promise<{ claim: MfaClaim; remainingDays?: number }> {
|
||||
const now = new Date();
|
||||
|
||||
if (!mfaGraceStartedAt) {
|
||||
// First login since enforcement — start the grace period
|
||||
await this.userRepo.updateMfaGraceStartedAt(userId, now);
|
||||
return { claim: 'grace', remainingDays: MFA_GRACE_PERIOD_DAYS };
|
||||
}
|
||||
|
||||
const elapsedMs = now.getTime() - mfaGraceStartedAt.getTime();
|
||||
const elapsedDays = elapsedMs / (1000 * 60 * 60 * 24);
|
||||
const remainingDays = Math.max(0, Math.ceil(MFA_GRACE_PERIOD_DAYS - elapsedDays));
|
||||
|
||||
if (remainingDays > 0) {
|
||||
return { claim: 'grace', remainingDays };
|
||||
}
|
||||
|
||||
// Grace period expired — enrollment is now mandatory
|
||||
return { claim: 'enrollment_required', remainingDays: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,12 @@ import { ForceDeleteUserHandler } from './application/commands/force-delete-user
|
||||
import { ForgotPasswordHandler } from './application/commands/forgot-password/forgot-password.handler';
|
||||
import { GenerateKycUploadUrlsHandler } from './application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.handler';
|
||||
import { LoginUserHandler } from './application/commands/login-user/login-user.handler';
|
||||
import { ResetPasswordHandler } from './application/commands/reset-password/reset-password.handler';
|
||||
import { ProcessScheduledDeletionsHandler } from './application/commands/process-scheduled-deletions/process-scheduled-deletions.handler';
|
||||
import { RefreshTokenHandler } from './application/commands/refresh-token/refresh-token.handler';
|
||||
import { RegisterUserHandler } from './application/commands/register-user/register-user.handler';
|
||||
import { RequestUserDeletionHandler } from './application/commands/request-user-deletion/request-user-deletion.handler';
|
||||
import { ResendOtpHandler } from './application/commands/resend-otp/resend-otp.handler';
|
||||
import { ResetPasswordHandler } from './application/commands/reset-password/reset-password.handler';
|
||||
import { SetupMfaHandler } from './application/commands/setup-mfa/setup-mfa.handler';
|
||||
import { SubmitKycHandler } from './application/commands/submit-kyc/submit-kyc.handler';
|
||||
import { UpdateProfileHandler } from './application/commands/update-profile/update-profile.handler';
|
||||
|
||||
@@ -17,10 +17,13 @@ export interface UserProps {
|
||||
kycStatus: KYCStatus;
|
||||
kycData: unknown;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
totpSecret: string | null;
|
||||
totpEnabled: boolean;
|
||||
totpBackupCodes: string[];
|
||||
totpEnabledAt: Date | null;
|
||||
mfaGraceStartedAt: Date | null;
|
||||
mfaLastVerifiedAt: Date | null;
|
||||
}
|
||||
|
||||
export class UserEntity extends AggregateRoot<string> {
|
||||
@@ -33,10 +36,13 @@ export class UserEntity extends AggregateRoot<string> {
|
||||
private _kycStatus: KYCStatus;
|
||||
private _kycData: unknown;
|
||||
private _isActive: boolean;
|
||||
private _deletedAt: Date | null;
|
||||
private _totpSecret: string | null;
|
||||
private _totpEnabled: boolean;
|
||||
private _totpBackupCodes: string[];
|
||||
private _totpEnabledAt: Date | null;
|
||||
private _mfaGraceStartedAt: Date | null;
|
||||
private _mfaLastVerifiedAt: Date | null;
|
||||
|
||||
constructor(id: string, props: UserProps, createdAt?: Date, updatedAt?: Date) {
|
||||
super(id, createdAt, updatedAt);
|
||||
@@ -49,10 +55,13 @@ export class UserEntity extends AggregateRoot<string> {
|
||||
this._kycStatus = props.kycStatus;
|
||||
this._kycData = props.kycData;
|
||||
this._isActive = props.isActive;
|
||||
this._deletedAt = props.deletedAt;
|
||||
this._totpSecret = props.totpSecret;
|
||||
this._totpEnabled = props.totpEnabled;
|
||||
this._totpBackupCodes = props.totpBackupCodes;
|
||||
this._totpEnabledAt = props.totpEnabledAt;
|
||||
this._mfaGraceStartedAt = props.mfaGraceStartedAt;
|
||||
this._mfaLastVerifiedAt = props.mfaLastVerifiedAt;
|
||||
}
|
||||
|
||||
get email(): Email | null { return this._email; }
|
||||
@@ -64,10 +73,13 @@ export class UserEntity extends AggregateRoot<string> {
|
||||
get kycStatus(): KYCStatus { return this._kycStatus; }
|
||||
get kycData(): unknown { return this._kycData; }
|
||||
get isActive(): boolean { return this._isActive; }
|
||||
get deletedAt(): Date | null { return this._deletedAt; }
|
||||
get totpSecret(): string | null { return this._totpSecret; }
|
||||
get totpEnabled(): boolean { return this._totpEnabled; }
|
||||
get totpBackupCodes(): string[] { return this._totpBackupCodes; }
|
||||
get totpEnabledAt(): Date | null { return this._totpEnabledAt; }
|
||||
get mfaGraceStartedAt(): Date | null { return this._mfaGraceStartedAt; }
|
||||
get mfaLastVerifiedAt(): Date | null { return this._mfaLastVerifiedAt; }
|
||||
|
||||
static createNew(
|
||||
id: string,
|
||||
@@ -87,10 +99,52 @@ export class UserEntity extends AggregateRoot<string> {
|
||||
kycStatus: 'NONE',
|
||||
kycData: null,
|
||||
isActive: true,
|
||||
deletedAt: null,
|
||||
totpSecret: null,
|
||||
totpEnabled: false,
|
||||
totpBackupCodes: [],
|
||||
totpEnabledAt: null,
|
||||
mfaGraceStartedAt: null,
|
||||
mfaLastVerifiedAt: null,
|
||||
});
|
||||
|
||||
user.addDomainEvent(new UserRegisteredEvent(id, phone.value, role));
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a passwordless user (e.g. via Phone-OTP login auto-register).
|
||||
* `passwordHash` is null so password login is not possible until the user
|
||||
* sets one via the password-reset / profile flow. A fullName fallback is
|
||||
* used since OTP signup does not collect a name.
|
||||
*/
|
||||
static createPasswordless(
|
||||
id: string,
|
||||
phone: Phone,
|
||||
fullName?: string,
|
||||
role: UserRole = 'BUYER',
|
||||
): UserEntity {
|
||||
const displayName =
|
||||
fullName && fullName.trim().length > 0
|
||||
? fullName
|
||||
: `Người dùng ${phone.value.slice(-4)}`;
|
||||
const user = new UserEntity(id, {
|
||||
email: null,
|
||||
phone,
|
||||
passwordHash: null,
|
||||
fullName: displayName,
|
||||
avatarUrl: null,
|
||||
role,
|
||||
kycStatus: 'NONE',
|
||||
kycData: null,
|
||||
isActive: true,
|
||||
deletedAt: null,
|
||||
totpSecret: null,
|
||||
totpEnabled: false,
|
||||
totpBackupCodes: [],
|
||||
totpEnabledAt: null,
|
||||
mfaGraceStartedAt: null,
|
||||
mfaLastVerifiedAt: null,
|
||||
});
|
||||
|
||||
user.addDomainEvent(new UserRegisteredEvent(id, phone.value, role));
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { type DomainEvent } from '@modules/shared';
|
||||
|
||||
export class PhoneLoginOtpRequestedEvent implements DomainEvent {
|
||||
readonly eventName = 'user.phone_login_otp_requested';
|
||||
readonly occurredAt = new Date();
|
||||
|
||||
constructor(
|
||||
public readonly aggregateId: string,
|
||||
public readonly phone: string,
|
||||
public readonly otpCode: string,
|
||||
) {}
|
||||
}
|
||||
28
apps/api/src/modules/auth/domain/mfa-policy.ts
Normal file
28
apps/api/src/modules/auth/domain/mfa-policy.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { UserRole } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* MFA enrolment policy — central source of truth for which roles require
|
||||
* TOTP and how long the grace period lasts.
|
||||
*
|
||||
* Backed by `User.mfaGraceStartedAt` and `User.mfaLastVerifiedAt` columns.
|
||||
*
|
||||
* Policy summary:
|
||||
* - On first login under enforcement, `mfaGraceStartedAt` is stamped.
|
||||
* - For `MFA_GRACE_PERIOD_DAYS` after that timestamp, the user keeps full
|
||||
* access but receives `mfa: 'grace'` in their JWT (UI nudges enrollment).
|
||||
* - After grace expires, the JWT carries `mfa: 'enrollment_required'` and
|
||||
* sensitive routes (admin guards) reject until the user enrols.
|
||||
*/
|
||||
|
||||
/** Roles for which TOTP is mandatory after the grace window expires. */
|
||||
export const MFA_REQUIRED_ROLES: ReadonlyArray<UserRole> = ['ADMIN'];
|
||||
|
||||
/** Length of the grace window before MFA enrolment becomes mandatory. */
|
||||
export const MFA_GRACE_PERIOD_DAYS = 14;
|
||||
|
||||
/**
|
||||
* Re-auth window for "step-up" admin operations (e.g. user impersonation,
|
||||
* mass actions). After this many minutes since `mfaLastVerifiedAt`, the
|
||||
* admin re-auth interceptor must challenge again.
|
||||
*/
|
||||
export const MFA_REAUTH_WINDOW_MINUTES = 15;
|
||||
@@ -12,4 +12,6 @@ export interface IUserRepository {
|
||||
updateMfaEnabled(userId: string, enabled: boolean, secret: string, backupCodes: string[]): Promise<void>;
|
||||
updateMfaDisabled(userId: string): Promise<void>;
|
||||
updateBackupCodes(userId: string, backupCodes: string[]): Promise<void>;
|
||||
updateMfaGraceStartedAt(userId: string, date: Date): Promise<void>;
|
||||
updateMfaLastVerifiedAt(userId: string, date: Date): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { AuthModule } from './auth.module';
|
||||
export { JwtAuthGuard } from './presentation/guards/jwt-auth.guard';
|
||||
export { OptionalJwtAuthGuard } from './presentation/guards/optional-jwt-auth.guard';
|
||||
export { RolesGuard } from './presentation/guards/roles.guard';
|
||||
export { Roles } from './presentation/decorators/roles.decorator';
|
||||
export { CurrentUser } from './presentation/decorators/current-user.decorator';
|
||||
@@ -16,3 +17,4 @@ export { PhoneChangeRequestedEvent } from './domain/events/phone-change-requeste
|
||||
export { EmailChangedEvent } from './domain/events/email-changed.event';
|
||||
export { PhoneChangedEvent } from './domain/events/phone-changed.event';
|
||||
export { USER_REPOSITORY, IUserRepository } from './domain/repositories/user.repository';
|
||||
export { PasswordResetRequestedEvent } from './domain/events/password-reset-requested.event';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { sign as jwtSign } from 'jsonwebtoken';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { verifyWithRotation, makeSecretOrKeyProvider } from '../utils/jwt-rotation';
|
||||
|
||||
const P = 'primary-secret-long-enough-for-hmac-signing-32!!';
|
||||
const Q = 'previous-secret-long-enough-for-hmac-signing-32!';
|
||||
const U = 'unknown-secret-long-enough-for-hmac-signing-32!!';
|
||||
const O = { audience: 'goodgo-api', issuer: 'goodgo-platform', expiresIn: '15m' } as const;
|
||||
const D = { sub: 'u1', phone: '0900000000', role: 'BUYER' };
|
||||
|
||||
describe('verifyWithRotation', () => {
|
||||
it('succeeds with primary', () => { expect(verifyWithRotation(jwtSign(D, P, O), P, undefined)).toMatchObject(D); });
|
||||
it('falls back to previous', () => { expect(verifyWithRotation(jwtSign(D, Q, O), P, Q)).toMatchObject(D); });
|
||||
it('null when both fail', () => { expect(verifyWithRotation(jwtSign(D, U, O), P, Q)).toBeNull(); });
|
||||
it('null without previous', () => { expect(verifyWithRotation(jwtSign(D, U, O), P, undefined)).toBeNull(); });
|
||||
it('null for expired', () => { expect(verifyWithRotation(jwtSign(D, P, { ...O, expiresIn: '-1s' }), P, undefined)).toBeNull(); });
|
||||
it('null for wrong audience', () => { expect(verifyWithRotation(jwtSign(D, P, { ...O, audience: 'x' }), P, undefined)).toBeNull(); });
|
||||
});
|
||||
|
||||
describe('makeSecretOrKeyProvider', () => {
|
||||
const call = (p: ReturnType<typeof makeSecretOrKeyProvider>, t: string) =>
|
||||
new Promise<{ err: Error | null; secret?: string }>((r) => p({}, t, (e, s) => r({ err: e, secret: s })));
|
||||
|
||||
it('returns primary for primary-signed', async () => { const r = await call(makeSecretOrKeyProvider(P, Q), jwtSign(D, P, O)); expect(r.secret).toBe(P); });
|
||||
it('returns previous for previous-signed', async () => { const r = await call(makeSecretOrKeyProvider(P, Q), jwtSign(D, Q, O)); expect(r.secret).toBe(Q); });
|
||||
it('returns primary when both fail', async () => { const r = await call(makeSecretOrKeyProvider(P, Q), jwtSign(D, U, O)); expect(r.secret).toBe(P); });
|
||||
});
|
||||
@@ -22,56 +22,199 @@ vi.mock('@nestjs/passport', () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Stub shared module imports so tests don't have to wire real Prisma/Redis.
|
||||
vi.mock('@modules/shared', () => ({
|
||||
PrismaService: class {},
|
||||
RedisService: class {},
|
||||
}));
|
||||
|
||||
type PrismaStub = { user: { findUnique: ReturnType<typeof vi.fn> } };
|
||||
type RedisStub = {
|
||||
isAvailable: ReturnType<typeof vi.fn>;
|
||||
get: ReturnType<typeof vi.fn>;
|
||||
set: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function makePrisma(user: { isActive: boolean; deletedAt: Date | null } | null): PrismaStub {
|
||||
return {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue(user),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeRedis(options: { available?: boolean; cached?: string | null } = {}): RedisStub {
|
||||
const { available = true, cached = null } = options;
|
||||
return {
|
||||
isAvailable: vi.fn().mockReturnValue(available),
|
||||
get: vi.fn().mockResolvedValue(cached),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
const ACTIVE_USER = { isActive: true, deletedAt: null };
|
||||
const BANNED_USER = { isActive: false, deletedAt: null };
|
||||
const DELETED_USER = { isActive: true, deletedAt: new Date('2026-01-01T00:00:00Z') };
|
||||
|
||||
describe('JwtStrategy', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('throws if JWT_SECRET is missing', async () => {
|
||||
vi.stubEnv('JWT_SECRET', '');
|
||||
expect(async () => {
|
||||
await expect(async () => {
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
new JwtStrategy();
|
||||
new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never);
|
||||
}).rejects.toThrow('JWT_SECRET environment variable is required');
|
||||
});
|
||||
|
||||
it('creates strategy when JWT_SECRET is set', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const strategy = new JwtStrategy();
|
||||
const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never);
|
||||
expect(strategy).toBeDefined();
|
||||
});
|
||||
|
||||
it('validate returns correct payload shape', async () => {
|
||||
it('validate returns the payload when user is active and not deleted', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const strategy = new JwtStrategy();
|
||||
const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never);
|
||||
|
||||
const payload = { sub: 'user-1', phone: '+84912345678', role: 'BUYER', iat: 12345, exp: 99999 };
|
||||
const result = strategy.validate(payload);
|
||||
const result = await strategy.validate(payload);
|
||||
|
||||
expect(result).toEqual({
|
||||
sub: 'user-1',
|
||||
phone: '+84912345678',
|
||||
role: 'BUYER',
|
||||
});
|
||||
expect(result).toEqual({ sub: 'user-1', phone: '+84912345678', role: 'BUYER' });
|
||||
});
|
||||
|
||||
it('validate strips extra fields from payload', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const strategy = new JwtStrategy();
|
||||
const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never);
|
||||
|
||||
const payload = { sub: 'user-2', phone: '+84987654321', role: 'ADMIN', iat: 12345, exp: 99999, extra: 'data' } as any;
|
||||
const result = strategy.validate(payload);
|
||||
|
||||
expect(result).toEqual({
|
||||
const payload = {
|
||||
sub: 'user-2',
|
||||
phone: '+84987654321',
|
||||
role: 'ADMIN',
|
||||
});
|
||||
iat: 12345,
|
||||
exp: 99999,
|
||||
extra: 'data',
|
||||
} as any;
|
||||
const result = await strategy.validate(payload);
|
||||
|
||||
expect(result).toEqual({ sub: 'user-2', phone: '+84987654321', role: 'ADMIN' });
|
||||
expect(result).not.toHaveProperty('extra');
|
||||
expect(result).not.toHaveProperty('iat');
|
||||
});
|
||||
|
||||
it('rejects banned user (isActive=false) with 401', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const prisma = makePrisma(BANNED_USER);
|
||||
const strategy = new JwtStrategy(prisma as never, makeRedis() as never);
|
||||
|
||||
await expect(
|
||||
strategy.validate({ sub: 'banned-1', phone: '+84911111111', role: 'BUYER' }),
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
it('rejects soft-deleted user (deletedAt !== null) with 401', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const strategy = new JwtStrategy(makePrisma(DELETED_USER) as never, makeRedis() as never);
|
||||
|
||||
await expect(
|
||||
strategy.validate({ sub: 'deleted-1', phone: '+84922222222', role: 'BUYER' }),
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
it('rejects when user does not exist in DB', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const strategy = new JwtStrategy(makePrisma(null) as never, makeRedis() as never);
|
||||
|
||||
await expect(
|
||||
strategy.validate({ sub: 'ghost-1', phone: '+84933333333', role: 'BUYER' }),
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
it('serves user status from Redis cache when present (no DB hit)', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const prisma = makePrisma(ACTIVE_USER);
|
||||
const redis = makeRedis({
|
||||
available: true,
|
||||
cached: JSON.stringify({ isActive: true, deletedAt: null }),
|
||||
});
|
||||
const strategy = new JwtStrategy(prisma as never, redis as never);
|
||||
|
||||
const result = await strategy.validate({ sub: 'user-cached', phone: '+84900000001', role: 'BUYER' });
|
||||
expect(result.sub).toBe('user-cached');
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
expect(redis.get).toHaveBeenCalled();
|
||||
expect(redis.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('populates Redis cache with 60s TTL after DB lookup', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||
const { JwtStrategy, USER_STATUS_CACHE_TTL_SECONDS, USER_STATUS_CACHE_PREFIX } = await import(
|
||||
'../strategies/jwt.strategy'
|
||||
);
|
||||
const prisma = makePrisma(ACTIVE_USER);
|
||||
const redis = makeRedis({ available: true, cached: null });
|
||||
const strategy = new JwtStrategy(prisma as never, redis as never);
|
||||
|
||||
await strategy.validate({ sub: 'user-miss', phone: '+84900000002', role: 'BUYER' });
|
||||
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
|
||||
expect(redis.set).toHaveBeenCalledTimes(1);
|
||||
const [key, value, ttl] = redis.set.mock.calls[0];
|
||||
expect(key).toBe(`${USER_STATUS_CACHE_PREFIX}:user-miss`);
|
||||
expect(JSON.parse(value)).toEqual({ isActive: true, deletedAt: null });
|
||||
expect(ttl).toBe(USER_STATUS_CACHE_TTL_SECONDS);
|
||||
expect(USER_STATUS_CACHE_TTL_SECONDS).toBe(60);
|
||||
});
|
||||
|
||||
it('falls back to DB when Redis is unavailable', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const prisma = makePrisma(ACTIVE_USER);
|
||||
const redis = makeRedis({ available: false });
|
||||
const strategy = new JwtStrategy(prisma as never, redis as never);
|
||||
|
||||
const result = await strategy.validate({ sub: 'user-rdown', phone: '+84900000003', role: 'BUYER' });
|
||||
expect(result.sub).toBe('user-rdown');
|
||||
expect(redis.get).not.toHaveBeenCalled();
|
||||
expect(redis.set).not.toHaveBeenCalled();
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to DB when Redis read throws', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const prisma = makePrisma(ACTIVE_USER);
|
||||
const redis = makeRedis({ available: true });
|
||||
redis.get.mockRejectedValueOnce(new Error('boom'));
|
||||
const strategy = new JwtStrategy(prisma as never, redis as never);
|
||||
|
||||
const result = await strategy.validate({ sub: 'user-rerr', phone: '+84900000004', role: 'BUYER' });
|
||||
expect(result.sub).toBe('user-rerr');
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('still rejects banned user when served from Redis cache', async () => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||
const redis = makeRedis({
|
||||
available: true,
|
||||
cached: JSON.stringify({ isActive: false, deletedAt: null }),
|
||||
});
|
||||
const strategy = new JwtStrategy(makePrisma(null) as never, redis as never);
|
||||
|
||||
await expect(
|
||||
strategy.validate({ sub: 'banned-cached', phone: '+84900000005', role: 'BUYER' }),
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,6 +87,7 @@ describe('LocalStrategy', () => {
|
||||
id: 'user-1',
|
||||
passwordHash: null,
|
||||
isActive: true,
|
||||
deletedAt: null,
|
||||
phone: { value: '+84912345678' },
|
||||
role: 'BUYER',
|
||||
});
|
||||
@@ -101,6 +102,7 @@ describe('LocalStrategy', () => {
|
||||
id: 'user-1',
|
||||
passwordHash: { compare: vi.fn().mockResolvedValue(true) },
|
||||
isActive: false,
|
||||
deletedAt: null,
|
||||
phone: { value: '+84912345678' },
|
||||
role: 'BUYER',
|
||||
});
|
||||
@@ -110,11 +112,27 @@ describe('LocalStrategy', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('throws 401 when user is soft-deleted (deletedAt set)', async () => {
|
||||
mockUserRepo.findByPhone.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
passwordHash: { compare: vi.fn().mockResolvedValue(true) },
|
||||
isActive: true,
|
||||
deletedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
phone: { value: '+84912345678' },
|
||||
role: 'BUYER',
|
||||
});
|
||||
|
||||
await expect(strategy.validate('0912345678', 'password')).rejects.toThrow(
|
||||
'Tài khoản đã bị xóa',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when password is wrong', async () => {
|
||||
mockUserRepo.findByPhone.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
passwordHash: { compare: vi.fn().mockResolvedValue(false) },
|
||||
isActive: true,
|
||||
deletedAt: null,
|
||||
phone: { value: '+84912345678' },
|
||||
role: 'BUYER',
|
||||
});
|
||||
@@ -129,6 +147,8 @@ describe('LocalStrategy', () => {
|
||||
id: 'user-1',
|
||||
passwordHash: { compare: vi.fn().mockResolvedValue(true) },
|
||||
isActive: true,
|
||||
deletedAt: null,
|
||||
totpEnabled: false,
|
||||
phone: { value: '+84912345678' },
|
||||
role: 'BUYER',
|
||||
});
|
||||
@@ -139,6 +159,9 @@ describe('LocalStrategy', () => {
|
||||
id: 'user-1',
|
||||
phone: '+84912345678',
|
||||
role: 'BUYER',
|
||||
isMfaRequired: false,
|
||||
totpEnabled: false,
|
||||
mfaGraceStartedAt: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,6 +196,7 @@ describe('LocalStrategy', () => {
|
||||
id: 'user-1',
|
||||
passwordHash: { compare: vi.fn().mockRejectedValue(new Error('bcrypt internal error')) },
|
||||
isActive: true,
|
||||
deletedAt: null,
|
||||
phone: { value: '+84912345678' },
|
||||
role: 'BUYER',
|
||||
});
|
||||
|
||||
@@ -1,158 +1,61 @@
|
||||
import { sign as jwtSign } from 'jsonwebtoken';
|
||||
import { type IRefreshTokenRepository, type RefreshTokenRecord } from '../../domain/repositories/refresh-token.repository';
|
||||
import { TokenService } from '../services/token.service';
|
||||
|
||||
const PRIMARY_SECRET = 'primary-secret-that-is-long-enough-for-tests-32chars!';
|
||||
const PREVIOUS_SECRET = 'previous-secret-that-is-long-enough-for-tests-32chars!';
|
||||
const JWT_SIGN_OPTS = { audience: 'goodgo-api', issuer: 'goodgo-platform', expiresIn: '15m' } as const;
|
||||
|
||||
describe('TokenService', () => {
|
||||
let service: TokenService;
|
||||
let mockJwtService: { sign: ReturnType<typeof vi.fn>; verify: ReturnType<typeof vi.fn> };
|
||||
let mockRefreshTokenRepo: { [K in keyof IRefreshTokenRepository]: ReturnType<typeof vi.fn> };
|
||||
|
||||
const payload = { sub: 'user-1', phone: '0912345678', role: 'BUYER' };
|
||||
|
||||
beforeEach(() => {
|
||||
mockJwtService = {
|
||||
sign: vi.fn().mockReturnValue('signed-jwt'),
|
||||
verify: vi.fn(),
|
||||
};
|
||||
mockRefreshTokenRepo = {
|
||||
create: vi.fn().mockResolvedValue({} as RefreshTokenRecord),
|
||||
findByToken: vi.fn(),
|
||||
revokeByFamily: vi.fn().mockResolvedValue(undefined),
|
||||
revokeAllForUser: vi.fn().mockResolvedValue(undefined),
|
||||
deleteExpired: vi.fn(),
|
||||
};
|
||||
|
||||
service = new TokenService(
|
||||
mockJwtService as any,
|
||||
mockRefreshTokenRepo as any,
|
||||
);
|
||||
process.env['JWT_SECRET'] = PRIMARY_SECRET;
|
||||
delete process.env['JWT_SECRET_PREVIOUS'];
|
||||
mockJwtService = { sign: vi.fn().mockReturnValue('signed-jwt'), verify: vi.fn() };
|
||||
mockRefreshTokenRepo = { create: vi.fn().mockResolvedValue({} as RefreshTokenRecord), findByToken: vi.fn(), revokeByFamily: vi.fn().mockResolvedValue(undefined), revokeAllForUser: vi.fn().mockResolvedValue(undefined), deleteExpired: vi.fn() };
|
||||
service = new TokenService(mockJwtService as any, mockRefreshTokenRepo as any);
|
||||
});
|
||||
|
||||
describe('generateTokenPair', () => {
|
||||
it('returns access token, refresh token with family prefix, and expiresIn', async () => {
|
||||
const result = await service.generateTokenPair(payload);
|
||||
|
||||
expect(result.accessToken).toBe('signed-jwt');
|
||||
expect(result.refreshToken).toContain('.');
|
||||
expect(result.expiresIn).toBe(900);
|
||||
expect(mockJwtService.sign).toHaveBeenCalledWith(payload);
|
||||
expect(mockRefreshTokenRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
revokedAt: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('creates refresh token record with 30-day expiry', async () => {
|
||||
await service.generateTokenPair(payload);
|
||||
|
||||
const createCall = mockRefreshTokenRepo.create.mock.calls[0][0];
|
||||
const expiresAt = createCall.expiresAt as Date;
|
||||
const now = new Date();
|
||||
const daysDiff = Math.round((expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const expiresAt = mockRefreshTokenRepo.create.mock.calls[0][0].expiresAt as Date;
|
||||
const daysDiff = Math.round((expiresAt.getTime() - Date.now()) / 86400000);
|
||||
expect(daysDiff).toBeGreaterThanOrEqual(29);
|
||||
expect(daysDiff).toBeLessThanOrEqual(31);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rotateRefreshToken', () => {
|
||||
const makeExistingToken = (overrides?: Partial<RefreshTokenRecord>): RefreshTokenRecord => ({
|
||||
id: 'rt-1',
|
||||
userId: 'user-1',
|
||||
token: 'hashed-token',
|
||||
family: 'old-family',
|
||||
expiresAt: new Date(Date.now() + 86400000),
|
||||
revokedAt: null,
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('rotates valid token: revokes old family, creates new token', async () => {
|
||||
mockRefreshTokenRepo.findByToken.mockResolvedValue(makeExistingToken());
|
||||
mockRefreshTokenRepo.create.mockResolvedValue({} as RefreshTokenRecord);
|
||||
|
||||
const result = await service.rotateRefreshToken('old-family.raw-token-hex');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.userId).toBe('user-1');
|
||||
expect(result!.refreshToken).toContain('.');
|
||||
expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalledWith('old-family');
|
||||
expect(mockRefreshTokenRepo.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null for malformed token (no dot separator)', async () => {
|
||||
const result = await service.rotateRefreshToken('no-dot-separator');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null and revokes family when token not found (reuse attack)', async () => {
|
||||
mockRefreshTokenRepo.findByToken.mockResolvedValue(null);
|
||||
|
||||
const result = await service.rotateRefreshToken('suspect-family.unknown-token');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalledWith('suspect-family');
|
||||
});
|
||||
|
||||
it('returns null and revokes family when token is already revoked', async () => {
|
||||
mockRefreshTokenRepo.findByToken.mockResolvedValue(
|
||||
makeExistingToken({ revokedAt: new Date() }),
|
||||
);
|
||||
|
||||
const result = await service.rotateRefreshToken('old-family.revoked-token');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null and revokes family when token is expired', async () => {
|
||||
mockRefreshTokenRepo.findByToken.mockResolvedValue(
|
||||
makeExistingToken({ expiresAt: new Date(Date.now() - 86400000) }),
|
||||
);
|
||||
|
||||
const result = await service.rotateRefreshToken('old-family.expired-token');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null for empty family segment', async () => {
|
||||
const result = await service.rotateRefreshToken('.some-raw-token');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty raw token segment', async () => {
|
||||
const result = await service.rotateRefreshToken('some-family.');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
const makeTok = (o?: Partial<RefreshTokenRecord>): RefreshTokenRecord => ({ id: 'rt-1', userId: 'user-1', token: 'h', family: 'old-family', expiresAt: new Date(Date.now() + 86400000), revokedAt: null, createdAt: new Date(), ...o });
|
||||
it('rotates valid token', async () => { mockRefreshTokenRepo.findByToken.mockResolvedValue(makeTok()); mockRefreshTokenRepo.create.mockResolvedValue({} as RefreshTokenRecord); const r = await service.rotateRefreshToken('old-family.raw'); expect(r).not.toBeNull(); expect(r!.userId).toBe('user-1'); });
|
||||
it('null for malformed', async () => { expect(await service.rotateRefreshToken('nodot')).toBeNull(); });
|
||||
it('null + revoke when not found', async () => { mockRefreshTokenRepo.findByToken.mockResolvedValue(null); expect(await service.rotateRefreshToken('f.t')).toBeNull(); expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalledWith('f'); });
|
||||
it('null when revoked', async () => { mockRefreshTokenRepo.findByToken.mockResolvedValue(makeTok({ revokedAt: new Date() })); expect(await service.rotateRefreshToken('old-family.t')).toBeNull(); });
|
||||
it('null when expired', async () => { mockRefreshTokenRepo.findByToken.mockResolvedValue(makeTok({ expiresAt: new Date(Date.now() - 86400000) })); expect(await service.rotateRefreshToken('old-family.t')).toBeNull(); });
|
||||
it('null for empty family', async () => { expect(await service.rotateRefreshToken('.raw')).toBeNull(); });
|
||||
it('null for empty raw', async () => { expect(await service.rotateRefreshToken('fam.')).toBeNull(); });
|
||||
});
|
||||
|
||||
describe('generateAccessToken', () => {
|
||||
it('delegates to jwtService.sign', () => {
|
||||
const token = service.generateAccessToken(payload);
|
||||
expect(token).toBe('signed-jwt');
|
||||
expect(mockJwtService.sign).toHaveBeenCalledWith(payload);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeAllUserTokens', () => {
|
||||
it('revokes all tokens for a user', async () => {
|
||||
await service.revokeAllUserTokens('user-1');
|
||||
expect(mockRefreshTokenRepo.revokeAllForUser).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
});
|
||||
describe('generateAccessToken', () => { it('delegates to jwtService.sign', () => { expect(service.generateAccessToken(payload)).toBe('signed-jwt'); }); });
|
||||
describe('revokeAllUserTokens', () => { it('revokes', async () => { await service.revokeAllUserTokens('user-1'); expect(mockRefreshTokenRepo.revokeAllForUser).toHaveBeenCalledWith('user-1'); }); });
|
||||
|
||||
describe('verifyAccessToken', () => {
|
||||
it('returns decoded payload for valid token', () => {
|
||||
mockJwtService.verify.mockReturnValue(payload);
|
||||
const result = service.verifyAccessToken('valid-jwt');
|
||||
expect(result).toEqual(payload);
|
||||
});
|
||||
|
||||
it('returns null for invalid token', () => {
|
||||
mockJwtService.verify.mockImplementation(() => { throw new Error('invalid'); });
|
||||
const result = service.verifyAccessToken('bad-jwt');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
function svc(p: string, q?: string) { const o = process.env['JWT_SECRET']; const oq = process.env['JWT_SECRET_PREVIOUS']; process.env['JWT_SECRET'] = p; if (q) process.env['JWT_SECRET_PREVIOUS'] = q; else delete process.env['JWT_SECRET_PREVIOUS']; const s = new TokenService(mockJwtService as any, mockRefreshTokenRepo as any); if (o) process.env['JWT_SECRET'] = o; if (oq) process.env['JWT_SECRET_PREVIOUS'] = oq; else delete process.env['JWT_SECRET_PREVIOUS']; return s; }
|
||||
it('primary succeeds', () => { expect(service.verifyAccessToken(jwtSign(payload, PRIMARY_SECRET, JWT_SIGN_OPTS))).toMatchObject(payload); });
|
||||
it('fallback to previous', () => { expect(svc(PRIMARY_SECRET, PREVIOUS_SECRET).verifyAccessToken(jwtSign(payload, PREVIOUS_SECRET, JWT_SIGN_OPTS))).toMatchObject(payload); });
|
||||
it('null when both fail', () => { expect(svc(PRIMARY_SECRET, PREVIOUS_SECRET).verifyAccessToken(jwtSign(payload, 'unknown-secret-that-is-long-enough-for-test!!!', JWT_SIGN_OPTS))).toBeNull(); });
|
||||
it('null for garbage', () => { expect(service.verifyAccessToken('garbage')).toBeNull(); });
|
||||
it('null for expired', () => { expect(service.verifyAccessToken(jwtSign(payload, PRIMARY_SECRET, { ...JWT_SIGN_OPTS, expiresIn: '-1s' }))).toBeNull(); });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,6 +123,14 @@ export class PrismaUserRepository implements IUserRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async updateMfaGraceStartedAt(userId: string, date: Date): Promise<void> {
|
||||
await this.prisma.user.update({ where: { id: userId }, data: { mfaGraceStartedAt: date } });
|
||||
}
|
||||
|
||||
async updateMfaLastVerifiedAt(userId: string, date: Date): Promise<void> {
|
||||
await this.prisma.user.update({ where: { id: userId }, data: { mfaLastVerifiedAt: date } });
|
||||
}
|
||||
|
||||
private toDomain(raw: PrismaUser): UserEntity {
|
||||
const phone = Phone.create(raw.phone).unwrap();
|
||||
const email = raw.email ? Email.create(raw.email).unwrap() : null;
|
||||
@@ -140,10 +148,13 @@ export class PrismaUserRepository implements IUserRepository {
|
||||
kycStatus: raw.kycStatus,
|
||||
kycData: raw.kycData,
|
||||
isActive: raw.isActive,
|
||||
deletedAt: raw.deletedAt,
|
||||
totpSecret: raw.totpSecret,
|
||||
totpEnabled: raw.totpEnabled,
|
||||
totpBackupCodes: raw.totpBackupCodes,
|
||||
totpEnabledAt: raw.totpEnabledAt,
|
||||
mfaGraceStartedAt: raw.mfaGraceStartedAt,
|
||||
mfaLastVerifiedAt: raw.mfaLastVerifiedAt,
|
||||
};
|
||||
|
||||
return new UserEntity(raw.id, props, raw.createdAt, raw.updatedAt);
|
||||
|
||||
@@ -121,10 +121,13 @@ export class OAuthService {
|
||||
kycStatus: 'NONE',
|
||||
kycData: null,
|
||||
isActive: true,
|
||||
deletedAt: null,
|
||||
totpSecret: null,
|
||||
totpEnabled: false,
|
||||
totpBackupCodes: [],
|
||||
totpEnabledAt: null,
|
||||
mfaGraceStartedAt: null,
|
||||
mfaLastVerifiedAt: null,
|
||||
});
|
||||
|
||||
await this.userRepo.save(user);
|
||||
|
||||
@@ -5,11 +5,25 @@ import {
|
||||
REFRESH_TOKEN_REPOSITORY,
|
||||
type IRefreshTokenRepository,
|
||||
} from '../../domain/repositories/refresh-token.repository';
|
||||
import { verifyWithRotation } from '../utils/jwt-rotation';
|
||||
|
||||
/**
|
||||
* MFA enrolment status carried inside the access-token JWT.
|
||||
*
|
||||
* - `none` — role does not require MFA, or user is enrolled and
|
||||
* has just verified (`requiresMfa === true` flow).
|
||||
* - `grace` — role requires MFA but the user is inside the
|
||||
* enforcement grace window. UI nudges enrollment.
|
||||
* - `enrollment_required`— grace window has expired; backend guards on
|
||||
* sensitive routes must reject and force enrollment.
|
||||
*/
|
||||
export type MfaClaim = 'none' | 'grace' | 'enrollment_required';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
phone: string;
|
||||
role: string;
|
||||
mfa?: MfaClaim;
|
||||
}
|
||||
|
||||
export interface TokenPair {
|
||||
@@ -26,102 +40,60 @@ export interface RotateResult {
|
||||
@Injectable()
|
||||
export class TokenService {
|
||||
private readonly REFRESH_TOKEN_EXPIRY_DAYS = 30;
|
||||
private readonly primarySecret: string;
|
||||
private readonly previousSecret: string | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
@Inject(REFRESH_TOKEN_REPOSITORY)
|
||||
private readonly refreshTokenRepo: IRefreshTokenRepository,
|
||||
) {}
|
||||
) {
|
||||
const secret = process.env['JWT_SECRET'];
|
||||
if (!secret) {
|
||||
throw new Error('JWT_SECRET environment variable is required');
|
||||
}
|
||||
this.primarySecret = secret;
|
||||
this.previousSecret = process.env['JWT_SECRET_PREVIOUS'] || undefined;
|
||||
}
|
||||
|
||||
async generateTokenPair(payload: JwtPayload): Promise<TokenPair> {
|
||||
const accessToken = this.jwtService.sign(payload);
|
||||
|
||||
const rawRefreshToken = randomBytes(64).toString('hex');
|
||||
const hashedToken = this.hashToken(rawRefreshToken);
|
||||
const family = randomBytes(16).toString('hex');
|
||||
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + this.REFRESH_TOKEN_EXPIRY_DAYS);
|
||||
|
||||
await this.refreshTokenRepo.create({
|
||||
userId: payload.sub,
|
||||
token: hashedToken,
|
||||
family,
|
||||
expiresAt,
|
||||
revokedAt: null,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: `${family}.${rawRefreshToken}`,
|
||||
expiresIn: 900,
|
||||
};
|
||||
await this.refreshTokenRepo.create({ userId: payload.sub, token: hashedToken, family, expiresAt, revokedAt: null });
|
||||
return { accessToken, refreshToken: `${family}.${rawRefreshToken}`, expiresIn: 900 };
|
||||
}
|
||||
|
||||
async rotateRefreshToken(refreshToken: string): Promise<RotateResult | null> {
|
||||
const dotIndex = refreshToken.indexOf('.');
|
||||
if (dotIndex === -1) return null;
|
||||
|
||||
const family = refreshToken.substring(0, dotIndex);
|
||||
const rawToken = refreshToken.substring(dotIndex + 1);
|
||||
if (!family || !rawToken) return null;
|
||||
|
||||
const hashedToken = this.hashToken(rawToken);
|
||||
const existing = await this.refreshTokenRepo.findByToken(hashedToken);
|
||||
|
||||
if (!existing) {
|
||||
// Possible token reuse attack — revoke entire family
|
||||
await this.refreshTokenRepo.revokeByFamily(family);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (existing.revokedAt || existing.expiresAt < new Date()) {
|
||||
await this.refreshTokenRepo.revokeByFamily(existing.family);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Revoke all tokens in this family
|
||||
if (!existing) { await this.refreshTokenRepo.revokeByFamily(family); return null; }
|
||||
if (existing.revokedAt || existing.expiresAt < new Date()) { await this.refreshTokenRepo.revokeByFamily(existing.family); return null; }
|
||||
await this.refreshTokenRepo.revokeByFamily(existing.family);
|
||||
|
||||
// Create new token in a new family
|
||||
const newRawToken = randomBytes(64).toString('hex');
|
||||
const newHashedToken = this.hashToken(newRawToken);
|
||||
const newFamily = randomBytes(16).toString('hex');
|
||||
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + this.REFRESH_TOKEN_EXPIRY_DAYS);
|
||||
|
||||
await this.refreshTokenRepo.create({
|
||||
userId: existing.userId,
|
||||
token: newHashedToken,
|
||||
family: newFamily,
|
||||
expiresAt,
|
||||
revokedAt: null,
|
||||
});
|
||||
|
||||
return {
|
||||
userId: existing.userId,
|
||||
refreshToken: `${newFamily}.${newRawToken}`,
|
||||
};
|
||||
await this.refreshTokenRepo.create({ userId: existing.userId, token: newHashedToken, family: newFamily, expiresAt, revokedAt: null });
|
||||
return { userId: existing.userId, refreshToken: `${newFamily}.${newRawToken}` };
|
||||
}
|
||||
|
||||
generateAccessToken(payload: JwtPayload): string {
|
||||
return this.jwtService.sign(payload);
|
||||
}
|
||||
generateAccessToken(payload: JwtPayload): string { return this.jwtService.sign(payload); }
|
||||
|
||||
async revokeAllUserTokens(userId: string): Promise<void> {
|
||||
await this.refreshTokenRepo.revokeAllForUser(userId);
|
||||
}
|
||||
async revokeAllUserTokens(userId: string): Promise<void> { await this.refreshTokenRepo.revokeAllForUser(userId); }
|
||||
|
||||
verifyAccessToken(token: string): JwtPayload | null {
|
||||
try {
|
||||
return this.jwtService.verify<JwtPayload>(token);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return verifyWithRotation<JwtPayload>(token, this.primarySecret, this.previousSecret);
|
||||
}
|
||||
|
||||
private hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
private hashToken(token: string): string { return createHash('sha256').update(token).digest('hex'); }
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { type Request } from 'express';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PrismaService, RedisService } from '@modules/shared';
|
||||
import { type JwtPayload } from '../services/token.service';
|
||||
import { makeSecretOrKeyProvider } from '../utils/jwt-rotation';
|
||||
|
||||
function extractJwtFromCookieOrHeader(req: Request): string | null {
|
||||
const cookieToken = req.cookies?.['access_token'] as string | undefined;
|
||||
@@ -10,24 +12,33 @@ function extractJwtFromCookieOrHeader(req: Request): string | null {
|
||||
return ExtractJwt.fromAuthHeaderAsBearerToken()(req);
|
||||
}
|
||||
|
||||
interface CachedUserStatus { isActive: boolean; deletedAt: string | null; }
|
||||
|
||||
export const USER_STATUS_CACHE_PREFIX = 'auth:user_status:v1';
|
||||
export const USER_STATUS_CACHE_TTL_SECONDS = 60;
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor() {
|
||||
constructor(private readonly prisma: PrismaService, private readonly redis: RedisService) {
|
||||
const jwtSecret = process.env['JWT_SECRET'];
|
||||
if (!jwtSecret) {
|
||||
throw new Error('JWT_SECRET environment variable is required');
|
||||
}
|
||||
|
||||
super({
|
||||
jwtFromRequest: extractJwtFromCookieOrHeader,
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: jwtSecret,
|
||||
audience: 'goodgo-api',
|
||||
issuer: 'goodgo-platform',
|
||||
});
|
||||
if (!jwtSecret) throw new Error('JWT_SECRET environment variable is required');
|
||||
const previousSecret = process.env['JWT_SECRET_PREVIOUS'] || undefined;
|
||||
super({ jwtFromRequest: extractJwtFromCookieOrHeader, ignoreExpiration: false, secretOrKeyProvider: makeSecretOrKeyProvider(jwtSecret, previousSecret), audience: 'goodgo-api', issuer: 'goodgo-platform' });
|
||||
}
|
||||
|
||||
validate(payload: JwtPayload): JwtPayload {
|
||||
async validate(payload: JwtPayload): Promise<JwtPayload> {
|
||||
const status = await this.loadUserStatus(payload.sub);
|
||||
if (!status || !status.isActive || status.deletedAt !== null) throw new UnauthorizedException('User account is inactive or deleted');
|
||||
return { sub: payload.sub, phone: payload.phone, role: payload.role };
|
||||
}
|
||||
|
||||
private async loadUserStatus(userId: string): Promise<CachedUserStatus | null> {
|
||||
const cacheKey = `${USER_STATUS_CACHE_PREFIX}:${userId}`;
|
||||
if (this.redis.isAvailable()) { try { const cached = await this.redis.get(cacheKey); if (cached !== null) return JSON.parse(cached) as CachedUserStatus; } catch { /* swallow */ } }
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { isActive: true, deletedAt: true } });
|
||||
if (!user) return null;
|
||||
const status: CachedUserStatus = { isActive: user.isActive, deletedAt: user.deletedAt ? user.deletedAt.toISOString() : null };
|
||||
if (this.redis.isAvailable()) { try { await this.redis.set(cacheKey, JSON.stringify(status), USER_STATUS_CACHE_TTL_SECONDS); } catch { /* swallow */ } }
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user