Compare commits
97 Commits
72aa7aab57
...
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 |
@@ -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' })
|
||||
|
||||
@@ -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,20 +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 { GetMarketReportHandler } from './application/queries/get-market-report/get-market-report.handler';
|
||||
import { GetMarketHistoryHandler } from './application/queries/get-market-history/get-market-history.handler';
|
||||
import { GetMarketReportHandler } from './application/queries/get-market-report/get-market-report.handler';
|
||||
import { GetMarketSnapshotHandler } from './application/queries/get-market-snapshot/get-market-snapshot.handler';
|
||||
import { GetPriceMoversHandler } from './application/queries/get-price-movers/get-price-movers.handler';
|
||||
import { GetProjectAiAdviceHandler } from './application/queries/get-project-ai-advice/get-project-ai-advice.handler';
|
||||
import { GetNearbyPOIsHandler } from './application/queries/get-nearby-pois/get-nearby-pois.handler';
|
||||
import { 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';
|
||||
@@ -40,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';
|
||||
|
||||
@@ -69,6 +77,7 @@ const QueryHandlers = [
|
||||
GetProjectAiAdviceHandler,
|
||||
GetMarketSnapshotHandler,
|
||||
GetPriceMoversHandler,
|
||||
GetTrendingAreasHandler,
|
||||
];
|
||||
|
||||
const EventHandlers = [
|
||||
@@ -76,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
|
||||
@@ -96,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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||
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';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type INeighborhoodScoreService, type NeighborhoodScoreResult } from '../../domain/services/neighborhood-score.service';
|
||||
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||
import { type 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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, CacheService, CachePrefix, CacheTTL, LoggerService, PrismaService } from '@modules/shared';
|
||||
import { type PropertyType, ListingStatus, Prisma } from '@prisma/client';
|
||||
import { DomainException, CacheService, CachePrefix, CacheTTL, LoggerService, PrismaService } from '@modules/shared';
|
||||
import { GetMarketSnapshotQuery } from './get-market-snapshot.query';
|
||||
|
||||
export interface PriceChangePct {
|
||||
|
||||
@@ -64,26 +64,26 @@ export class GetPriceMoversHandler implements IQueryHandler<GetPriceMoversQuery>
|
||||
WITH current_window AS (
|
||||
SELECT
|
||||
p.district,
|
||||
AVG(l.price) AS avg_price,
|
||||
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.price > 0
|
||||
AND l."priceVND" > 0
|
||||
GROUP BY p.district
|
||||
HAVING COUNT(l.id) >= 10
|
||||
),
|
||||
previous_window AS (
|
||||
SELECT
|
||||
p.district,
|
||||
AVG(l.price) AS avg_price
|
||||
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.price > 0
|
||||
AND l."priceVND" > 0
|
||||
GROUP BY p.district
|
||||
)
|
||||
SELECT
|
||||
|
||||
@@ -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',
|
||||
) {}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -146,22 +146,35 @@ export class PrismaMarketIndexRepository implements IMarketIndexRepository {
|
||||
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 districtFilter = district ? `AND p."district" = ${JSON.stringify(district)}` : '';
|
||||
|
||||
const rows = await this.prisma.$queryRawUnsafe<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" = $1 ${districtFilter}
|
||||
AND p."ward" IS NOT NULL AND p."ward" != ''
|
||||
GROUP BY p."ward", p."district"
|
||||
ORDER BY p."ward" ASC
|
||||
`, city);
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { type ExecutionContext, type CallHandler } from '@nestjs/common';
|
||||
import { of } from 'rxjs';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import { of, lastValueFrom } from 'rxjs';
|
||||
import { cacheMetaStorage } from '@modules/shared';
|
||||
import { CacheMetaInterceptor, type WithCacheMeta } from '../interceptors/cache-meta.interceptor';
|
||||
|
||||
|
||||
@@ -13,36 +13,37 @@ import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody, ApiParam }
|
||||
import { JwtAuthGuard } from '@modules/auth';
|
||||
import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared';
|
||||
import { RequireQuota, QuotaGuard } from '@modules/subscriptions';
|
||||
import { CacheMetaInterceptor } from '../interceptors/cache-meta.interceptor';
|
||||
import { type BatchValuationDto as BatchValuationQueryDto } from '../../application/queries/batch-valuation/batch-valuation.handler';
|
||||
import { BatchValuationQuery } from '../../application/queries/batch-valuation/batch-valuation.query';
|
||||
import { type DistrictStatsDto } from '../../application/queries/get-district-stats/get-district-stats.handler';
|
||||
import { GetDistrictStatsQuery } from '../../application/queries/get-district-stats/get-district-stats.query';
|
||||
import { type HeatmapDto } from '../../application/queries/get-heatmap/get-heatmap.handler';
|
||||
import { GetHeatmapQuery } from '../../application/queries/get-heatmap/get-heatmap.query';
|
||||
import { type ListingVolumeWardDto } from '../../application/queries/get-listing-volume-ward/get-listing-volume-ward.handler';
|
||||
import { GetListingVolumeWardQuery } from '../../application/queries/get-listing-volume-ward/get-listing-volume-ward.query';
|
||||
import {
|
||||
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 MarketHistoryDto } from '../../application/queries/get-market-history/get-market-history.handler';
|
||||
import { GetMarketHistoryQuery } from '../../application/queries/get-market-history/get-market-history.query';
|
||||
import { type MarketSnapshotDto } from '../../application/queries/get-market-snapshot/get-market-snapshot.handler';
|
||||
import { GetMarketSnapshotQuery } from '../../application/queries/get-market-snapshot/get-market-snapshot.query';
|
||||
import { type PriceMoversDto } from '../../application/queries/get-price-movers/get-price-movers.handler';
|
||||
import { GetPriceMoversQuery } from '../../application/queries/get-price-movers/get-price-movers.query';
|
||||
import { type 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';
|
||||
@@ -56,16 +57,18 @@ 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 { GetMarketReportDto } from '../dto/get-market-report.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 { GetPriceMoversDto } from '../dto/get-price-movers.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)
|
||||
@@ -356,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')
|
||||
|
||||
@@ -27,8 +27,8 @@ import { AvmCompareQueryDto } from '../dto/avm-compare-query.dto';
|
||||
import { AvmExplainQueryDto } from '../dto/avm-explain-query.dto';
|
||||
import { BatchValuationDto } from '../dto/batch-valuation.dto';
|
||||
import { IndustrialValuationDto } from '../dto/industrial-valuation.dto';
|
||||
import { CacheMetaInterceptor } from '../interceptors/cache-meta.interceptor';
|
||||
import { ValuationHistoryDto } from '../dto/valuation-history.dto';
|
||||
import { CacheMetaInterceptor } from '../interceptors/cache-meta.interceptor';
|
||||
|
||||
@ApiTags('avm')
|
||||
@UseInterceptors(CacheMetaInterceptor)
|
||||
|
||||
@@ -43,5 +43,5 @@ export class GetPriceMoversDto {
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['district'])
|
||||
level: 'district' = '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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -17,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface LocalStrategyResult {
|
||||
phone: string;
|
||||
role: string;
|
||||
isMfaRequired: boolean;
|
||||
totpEnabled: boolean;
|
||||
mfaGraceStartedAt: Date | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -42,6 +44,10 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||
throw new UnauthorizedException('Tài khoản đã bị vô hiệu hóa');
|
||||
}
|
||||
|
||||
if (user.deletedAt !== null) {
|
||||
throw new UnauthorizedException('Tài khoản đã bị xóa');
|
||||
}
|
||||
|
||||
const isValid = await user.passwordHash.compare(password);
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('Số điện thoại hoặc mật khẩu không đúng');
|
||||
@@ -52,6 +58,8 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||
phone: user.phone.value,
|
||||
role: user.role,
|
||||
isMfaRequired: user.totpEnabled,
|
||||
totpEnabled: user.totpEnabled,
|
||||
mfaGraceStartedAt: user.mfaGraceStartedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { verify as jwtVerify, type JwtPayload as JsonWebTokenPayload } from 'jsonwebtoken';
|
||||
|
||||
const JWT_VERIFY_OPTIONS = { audience: 'goodgo-api', issuer: 'goodgo-platform' } as const;
|
||||
|
||||
export function verifyWithRotation<T extends object = JsonWebTokenPayload>(
|
||||
token: string, primarySecret: string, previousSecret: string | undefined,
|
||||
): T | null {
|
||||
try { return jwtVerify(token, primarySecret, JWT_VERIFY_OPTIONS) as T; } catch { /* primary failed */ }
|
||||
if (previousSecret) { try { return jwtVerify(token, previousSecret, JWT_VERIFY_OPTIONS) as T; } catch { /* both failed */ } }
|
||||
return null;
|
||||
}
|
||||
|
||||
export function makeSecretOrKeyProvider(
|
||||
primarySecret: string, previousSecret: string | undefined,
|
||||
): (request: unknown, rawJwtToken: string, done: (err: Error | null, secret?: string) => void) => void {
|
||||
return (_request: unknown, rawJwtToken: string, done: (err: Error | null, secret?: string) => void) => {
|
||||
try { jwtVerify(rawJwtToken, primarySecret, JWT_VERIFY_OPTIONS); return done(null, primarySecret); } catch { /* primary failed */ }
|
||||
if (previousSecret) { try { jwtVerify(rawJwtToken, previousSecret, JWT_VERIFY_OPTIONS); return done(null, previousSecret); } catch { /* both failed */ } }
|
||||
return done(null, primarySecret);
|
||||
};
|
||||
}
|
||||
@@ -62,7 +62,23 @@ import { RolesGuard } from '../guards/roles.guard';
|
||||
|
||||
const IS_PRODUCTION = process.env['NODE_ENV'] === 'production';
|
||||
const IS_TEST = process.env['NODE_ENV'] === 'test';
|
||||
const AUTH_RATE_LIMIT = IS_TEST ? 10_000 : 5;
|
||||
/**
|
||||
* Hourly rate limit for auth endpoints. Default 5 is the production
|
||||
* safety threshold; raise via env in dev/staging when exercising flows
|
||||
* (e.g. `AUTH_RATE_LIMIT=200` in the cluster ConfigMap so testers
|
||||
* don't lock themselves out after a few attempts).
|
||||
*/
|
||||
const AUTH_RATE_LIMIT = (() => {
|
||||
if (IS_TEST) return 10_000;
|
||||
const fromEnv = Number(process.env['AUTH_RATE_LIMIT']);
|
||||
return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : 5;
|
||||
})();
|
||||
/** Per-IP burst limit for the login / register endpoints (per minute). */
|
||||
const AUTH_PER_IP_LIMIT = (() => {
|
||||
if (IS_TEST) return 10_000;
|
||||
const fromEnv = Number(process.env['AUTH_PER_IP_LIMIT']);
|
||||
return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : 5;
|
||||
})();
|
||||
const ACCESS_TOKEN_MAX_AGE = 15 * 60 * 1000; // 15 minutes
|
||||
const REFRESH_TOKEN_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
const AUTH_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
@@ -109,7 +125,7 @@ export class AuthController {
|
||||
) {}
|
||||
|
||||
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
||||
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 3, windowSeconds: 60, keyStrategy: 'ip' })
|
||||
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'ip' })
|
||||
@UseGuards(EndpointRateLimitGuard)
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: 'Register a new user' })
|
||||
@@ -132,7 +148,7 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
||||
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'ip' })
|
||||
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'ip' })
|
||||
@UseGuards(EndpointRateLimitGuard, LocalAuthGuard)
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: 'Login with phone and password' })
|
||||
@@ -198,7 +214,7 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
||||
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 3, windowSeconds: 60, keyStrategy: 'ip' })
|
||||
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'ip' })
|
||||
@UseGuards(EndpointRateLimitGuard)
|
||||
@Post('forgot-password')
|
||||
@ApiOperation({
|
||||
@@ -215,7 +231,7 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
||||
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'ip' })
|
||||
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'ip' })
|
||||
@UseGuards(EndpointRateLimitGuard)
|
||||
@Post('reset-password')
|
||||
@ApiOperation({ summary: 'Reset password using OTP code' })
|
||||
@@ -230,10 +246,14 @@ export class AuthController {
|
||||
);
|
||||
}
|
||||
|
||||
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: 20 } })
|
||||
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'ip' })
|
||||
@UseGuards(EndpointRateLimitGuard)
|
||||
@Post('exchange-token')
|
||||
@ApiOperation({ summary: 'Exchange OAuth token pair for httpOnly cookies' })
|
||||
@ApiResponse({ status: 201, description: 'Auth cookies set' })
|
||||
@ApiResponse({ status: 401, description: 'Invalid access token' })
|
||||
@ApiResponse({ status: 429, description: 'Too many requests' })
|
||||
async exchangeToken(
|
||||
@Body() body: { accessToken: string; refreshToken: string; expiresIn?: number },
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@@ -282,7 +302,7 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
||||
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'user' })
|
||||
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'user' })
|
||||
@UseGuards(JwtAuthGuard, EndpointRateLimitGuard)
|
||||
@Post('profile/verify-phone')
|
||||
@ApiBearerAuth('JWT')
|
||||
@@ -303,7 +323,7 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
||||
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'user' })
|
||||
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'user' })
|
||||
@UseGuards(JwtAuthGuard, EndpointRateLimitGuard)
|
||||
@Post('profile/verify-email')
|
||||
@ApiBearerAuth('JWT')
|
||||
|
||||
@@ -5,13 +5,16 @@ import {
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { CommandBus } from '@nestjs/cqrs';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiProduces } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { CancelUserDeletionCommand } from '../../application/commands/cancel-user-deletion/cancel-user-deletion.command';
|
||||
import { ExportUserDataCommand } from '../../application/commands/export-user-data/export-user-data.command';
|
||||
import { type UserDataExport } from '../../application/commands/export-user-data/export-user-data.handler';
|
||||
import { type ExportUserDataResult } from '../../application/commands/export-user-data/export-user-data.handler';
|
||||
import { ForceDeleteUserCommand } from '../../application/commands/force-delete-user/force-delete-user.command';
|
||||
import { RequestUserDeletionCommand } from '../../application/commands/request-user-deletion/request-user-deletion.command';
|
||||
import { type JwtPayload } from '../../infrastructure/services/token.service';
|
||||
@@ -58,13 +61,33 @@ export class UserDataController {
|
||||
@Get('me/export')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Export user data (GDPR Article 20)' })
|
||||
@ApiResponse({ status: 200, description: 'User data exported as JSON' })
|
||||
@ApiProduces('application/json')
|
||||
@ApiOperation({
|
||||
summary: 'Export user data (GDPR Article 20)',
|
||||
description:
|
||||
'Streams the full user data export as JSON. ' +
|
||||
'Row cap (per collection) defaults to 10 000 rows; size cap defaults to 100 MB. ' +
|
||||
'Both are configurable via EXPORT_ROW_CAP and EXPORT_SIZE_CAP_MB env vars.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'User data exported as streaming JSON' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({
|
||||
status: 413,
|
||||
description: 'Export exceeds size cap — contact support for chunked export',
|
||||
})
|
||||
async exportData(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<UserDataExport> {
|
||||
return this.commandBus.execute(new ExportUserDataCommand(user.sub));
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<StreamableFile> {
|
||||
const result: ExportUserDataResult = await this.commandBus.execute(
|
||||
new ExportUserDataCommand(user.sub),
|
||||
);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="user-data-${user.sub}.json"`,
|
||||
);
|
||||
return new StreamableFile(result.stream);
|
||||
}
|
||||
|
||||
@Delete(':id/force')
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { NotFoundException, ValidationException } from '@modules/shared';
|
||||
import { PropertyDocumentEntity } from '../../domain/entities/property-document.entity';
|
||||
import { type IPropertyDocumentRepository } from '../../domain/repositories/property-document.repository';
|
||||
import { ApproveDocumentCommand } from '../commands/approve-document/approve-document.command';
|
||||
import { ApproveDocumentHandler } from '../commands/approve-document/approve-document.handler';
|
||||
|
||||
describe('ApproveDocumentHandler', () => {
|
||||
let handler: ApproveDocumentHandler;
|
||||
let mockDocRepo: { [K in keyof IPropertyDocumentRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockPrisma: { property: { update: ReturnType<typeof vi.fn> } };
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn>; debug: ReturnType<typeof vi.fn>; verbose: ReturnType<typeof vi.fn> };
|
||||
|
||||
const createPendingDoc = (id = 'doc-1') =>
|
||||
PropertyDocumentEntity.createNew(
|
||||
id, 'prop-1', 'user-1', 'SO_DO',
|
||||
'http://storage.local/documents/test.pdf',
|
||||
'sodo.pdf', 'application/pdf', 1024,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockDocRepo = {
|
||||
findById: vi.fn(),
|
||||
findByPropertyId: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn(),
|
||||
findPendingReview: vi.fn(),
|
||||
countApprovedByPropertyId: vi.fn(),
|
||||
};
|
||||
|
||||
mockPrisma = {
|
||||
property: {
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
|
||||
|
||||
handler = new ApproveDocumentHandler(
|
||||
mockDocRepo as any,
|
||||
mockPrisma as any,
|
||||
mockLogger as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('approves a pending document successfully', async () => {
|
||||
const doc = createPendingDoc();
|
||||
mockDocRepo.findById.mockResolvedValue(doc);
|
||||
|
||||
const command = new ApproveDocumentCommand('doc-1', 'admin-1');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.documentId).toBe('doc-1');
|
||||
expect(result.status).toBe('APPROVED');
|
||||
expect(result.message).toContain('xác minh thành công');
|
||||
expect(mockDocRepo.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('updates the document entity status to APPROVED', async () => {
|
||||
const doc = createPendingDoc();
|
||||
mockDocRepo.findById.mockResolvedValue(doc);
|
||||
|
||||
const command = new ApproveDocumentCommand('doc-1', 'admin-1');
|
||||
await handler.execute(command);
|
||||
|
||||
const updatedDoc = mockDocRepo.update.mock.calls[0]![0];
|
||||
expect(updatedDoc.status).toBe('APPROVED');
|
||||
expect(updatedDoc.reviewedById).toBe('admin-1');
|
||||
expect(updatedDoc.reviewedAt).not.toBeNull();
|
||||
expect(updatedDoc.rejectionReason).toBeNull();
|
||||
});
|
||||
|
||||
it('sets certificateVerified on the property', async () => {
|
||||
const doc = createPendingDoc();
|
||||
mockDocRepo.findById.mockResolvedValue(doc);
|
||||
|
||||
const command = new ApproveDocumentCommand('doc-1', 'admin-1');
|
||||
await handler.execute(command);
|
||||
|
||||
expect(mockPrisma.property.update).toHaveBeenCalledWith({
|
||||
where: { id: 'prop-1' },
|
||||
data: { certificateVerified: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws NotFoundException when document does not exist', async () => {
|
||||
mockDocRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = new ApproveDocumentCommand('nonexistent', 'admin-1');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('throws ValidationException when document is not PENDING_REVIEW', async () => {
|
||||
const doc = createPendingDoc();
|
||||
doc.approve('admin-old'); // status becomes APPROVED
|
||||
mockDocRepo.findById.mockResolvedValue(doc);
|
||||
|
||||
const command = new ApproveDocumentCommand('doc-1', 'admin-2');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(ValidationException);
|
||||
await expect(handler.execute(command)).rejects.toThrow(/APPROVED/);
|
||||
});
|
||||
|
||||
it('throws ValidationException for REJECTED document', async () => {
|
||||
const doc = createPendingDoc();
|
||||
doc.reject('admin-old', 'bad'); // status becomes REJECTED
|
||||
mockDocRepo.findById.mockResolvedValue(doc);
|
||||
|
||||
const command = new ApproveDocumentCommand('doc-1', 'admin-2');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(ValidationException);
|
||||
await expect(handler.execute(command)).rejects.toThrow(/REJECTED/);
|
||||
});
|
||||
|
||||
it('re-throws DomainException without wrapping', async () => {
|
||||
mockDocRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = new ApproveDocumentCommand('doc-1', 'admin-1');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(NotFoundException);
|
||||
await expect(handler.execute(command)).rejects.not.toThrow(InternalServerErrorException);
|
||||
});
|
||||
|
||||
it('wraps unexpected errors in InternalServerErrorException', async () => {
|
||||
mockDocRepo.findById.mockRejectedValue(new Error('DB timeout'));
|
||||
|
||||
const command = new ApproveDocumentCommand('doc-1', 'admin-1');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(InternalServerErrorException);
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts optional notes parameter', () => {
|
||||
const command = new ApproveDocumentCommand('doc-1', 'admin-1', 'Giay to hop le');
|
||||
expect(command.notes).toBe('Giay to hop le');
|
||||
});
|
||||
|
||||
it('notes parameter is undefined when not provided', () => {
|
||||
const command = new ApproveDocumentCommand('doc-1', 'admin-1');
|
||||
expect(command.notes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { PropertyDocumentEntity } from '../../domain/entities/property-document.entity';
|
||||
import { type IPropertyDocumentRepository } from '../../domain/repositories/property-document.repository';
|
||||
import { GetPendingDocumentsHandler } from '../queries/get-pending-documents/get-pending-documents.handler';
|
||||
import { GetPendingDocumentsQuery } from '../queries/get-pending-documents/get-pending-documents.query';
|
||||
|
||||
describe('GetPendingDocumentsHandler', () => {
|
||||
let handler: GetPendingDocumentsHandler;
|
||||
let mockDocRepo: { [K in keyof IPropertyDocumentRepository]: ReturnType<typeof vi.fn> };
|
||||
|
||||
const createDoc = (id: string, propertyId = 'prop-1') =>
|
||||
PropertyDocumentEntity.createNew(
|
||||
id, propertyId, 'user-1', 'SO_DO',
|
||||
'http://storage.local/documents/test.pdf',
|
||||
'sodo.pdf', 'application/pdf', 1024, 'Mo ta',
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockDocRepo = {
|
||||
findById: vi.fn(),
|
||||
findByPropertyId: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findPendingReview: vi.fn(),
|
||||
countApprovedByPropertyId: vi.fn(),
|
||||
};
|
||||
|
||||
handler = new GetPendingDocumentsHandler(mockDocRepo as any);
|
||||
});
|
||||
|
||||
it('returns paginated pending documents', async () => {
|
||||
const docs = [createDoc('doc-1'), createDoc('doc-2')];
|
||||
mockDocRepo.findPendingReview.mockResolvedValue({ items: docs, total: 5 });
|
||||
|
||||
const query = new GetPendingDocumentsQuery(1, 2);
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.total).toBe(5);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.limit).toBe(2);
|
||||
expect(mockDocRepo.findPendingReview).toHaveBeenCalledWith(1, 2);
|
||||
});
|
||||
|
||||
it('maps entity fields to DTO correctly', async () => {
|
||||
const doc = createDoc('doc-1');
|
||||
mockDocRepo.findPendingReview.mockResolvedValue({ items: [doc], total: 1 });
|
||||
|
||||
const query = new GetPendingDocumentsQuery(1, 10);
|
||||
const result = await handler.execute(query);
|
||||
|
||||
const item = result.items[0]!;
|
||||
expect(item.id).toBe('doc-1');
|
||||
expect(item.propertyId).toBe('prop-1');
|
||||
expect(item.uploadedById).toBe('user-1');
|
||||
expect(item.documentType).toBe('SO_DO');
|
||||
expect(item.status).toBe('PENDING_REVIEW');
|
||||
expect(item.url).toBe('http://storage.local/documents/test.pdf');
|
||||
expect(item.fileName).toBe('sodo.pdf');
|
||||
expect(item.mimeType).toBe('application/pdf');
|
||||
expect(item.fileSizeBytes).toBe(1024);
|
||||
expect(item.description).toBe('Mo ta');
|
||||
expect(item.rejectionReason).toBeNull();
|
||||
expect(item.reviewedById).toBeNull();
|
||||
expect(item.reviewedAt).toBeNull();
|
||||
expect(item.createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('returns empty items when no pending documents', async () => {
|
||||
mockDocRepo.findPendingReview.mockResolvedValue({ items: [], total: 0 });
|
||||
|
||||
const query = new GetPendingDocumentsQuery(1, 20);
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.items).toHaveLength(0);
|
||||
expect(result.total).toBe(0);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.limit).toBe(20);
|
||||
});
|
||||
|
||||
it('passes page and limit from query to repository', async () => {
|
||||
mockDocRepo.findPendingReview.mockResolvedValue({ items: [], total: 0 });
|
||||
|
||||
const query = new GetPendingDocumentsQuery(3, 50);
|
||||
await handler.execute(query);
|
||||
|
||||
expect(mockDocRepo.findPendingReview).toHaveBeenCalledWith(3, 50);
|
||||
});
|
||||
|
||||
it('returns correct page and limit in result', async () => {
|
||||
mockDocRepo.findPendingReview.mockResolvedValue({ items: [], total: 100 });
|
||||
|
||||
const query = new GetPendingDocumentsQuery(5, 25);
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.page).toBe(5);
|
||||
expect(result.limit).toBe(25);
|
||||
expect(result.total).toBe(100);
|
||||
});
|
||||
|
||||
it('handles multiple documents from different properties', async () => {
|
||||
const docs = [
|
||||
createDoc('doc-1', 'prop-1'),
|
||||
createDoc('doc-2', 'prop-2'),
|
||||
createDoc('doc-3', 'prop-3'),
|
||||
];
|
||||
mockDocRepo.findPendingReview.mockResolvedValue({ items: docs, total: 3 });
|
||||
|
||||
const query = new GetPendingDocumentsQuery(1, 10);
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.items).toHaveLength(3);
|
||||
expect(result.items[0]!.propertyId).toBe('prop-1');
|
||||
expect(result.items[1]!.propertyId).toBe('prop-2');
|
||||
expect(result.items[2]!.propertyId).toBe('prop-3');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { PropertyDocumentEntity } from '../../domain/entities/property-document.entity';
|
||||
import { type IPropertyDocumentRepository } from '../../domain/repositories/property-document.repository';
|
||||
import { GetPropertyDocumentsHandler } from '../queries/get-property-documents/get-property-documents.handler';
|
||||
import { GetPropertyDocumentsQuery } from '../queries/get-property-documents/get-property-documents.query';
|
||||
|
||||
describe('GetPropertyDocumentsHandler', () => {
|
||||
let handler: GetPropertyDocumentsHandler;
|
||||
let mockDocRepo: { [K in keyof IPropertyDocumentRepository]: ReturnType<typeof vi.fn> };
|
||||
|
||||
const createDoc = (id: string, docType: 'SO_DO' | 'SO_HONG' | 'GCNQSD' | 'OTHER' = 'SO_DO') =>
|
||||
PropertyDocumentEntity.createNew(
|
||||
id, 'prop-1', 'user-1', docType,
|
||||
'http://storage.local/documents/test.pdf',
|
||||
'doc.pdf', 'application/pdf', 1024, 'Mo ta',
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockDocRepo = {
|
||||
findById: vi.fn(),
|
||||
findByPropertyId: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findPendingReview: vi.fn(),
|
||||
countApprovedByPropertyId: vi.fn(),
|
||||
};
|
||||
|
||||
handler = new GetPropertyDocumentsHandler(mockDocRepo as any);
|
||||
});
|
||||
|
||||
it('returns documents for a property', async () => {
|
||||
const docs = [createDoc('doc-1'), createDoc('doc-2')];
|
||||
mockDocRepo.findByPropertyId.mockResolvedValue(docs);
|
||||
|
||||
const query = new GetPropertyDocumentsQuery('prop-1');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(mockDocRepo.findByPropertyId).toHaveBeenCalledWith('prop-1');
|
||||
});
|
||||
|
||||
it('maps entity fields to DTO correctly', async () => {
|
||||
const doc = createDoc('doc-1');
|
||||
mockDocRepo.findByPropertyId.mockResolvedValue([doc]);
|
||||
|
||||
const query = new GetPropertyDocumentsQuery('prop-1');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
const item = result[0]!;
|
||||
expect(item.id).toBe('doc-1');
|
||||
expect(item.propertyId).toBe('prop-1');
|
||||
expect(item.uploadedById).toBe('user-1');
|
||||
expect(item.documentType).toBe('SO_DO');
|
||||
expect(item.status).toBe('PENDING_REVIEW');
|
||||
expect(item.url).toBe('http://storage.local/documents/test.pdf');
|
||||
expect(item.fileName).toBe('doc.pdf');
|
||||
expect(item.mimeType).toBe('application/pdf');
|
||||
expect(item.fileSizeBytes).toBe(1024);
|
||||
expect(item.description).toBe('Mo ta');
|
||||
expect(item.rejectionReason).toBeNull();
|
||||
expect(item.reviewedById).toBeNull();
|
||||
expect(item.reviewedAt).toBeNull();
|
||||
expect(item.createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('returns empty array when no documents exist', async () => {
|
||||
mockDocRepo.findByPropertyId.mockResolvedValue([]);
|
||||
|
||||
const query = new GetPropertyDocumentsQuery('prop-empty');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
expect(mockDocRepo.findByPropertyId).toHaveBeenCalledWith('prop-empty');
|
||||
});
|
||||
|
||||
it('maps documents with different types', async () => {
|
||||
const docs = [
|
||||
createDoc('doc-1', 'SO_DO'),
|
||||
createDoc('doc-2', 'SO_HONG'),
|
||||
createDoc('doc-3', 'GCNQSD'),
|
||||
createDoc('doc-4', 'OTHER'),
|
||||
];
|
||||
mockDocRepo.findByPropertyId.mockResolvedValue(docs);
|
||||
|
||||
const query = new GetPropertyDocumentsQuery('prop-1');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result[0]!.documentType).toBe('SO_DO');
|
||||
expect(result[1]!.documentType).toBe('SO_HONG');
|
||||
expect(result[2]!.documentType).toBe('GCNQSD');
|
||||
expect(result[3]!.documentType).toBe('OTHER');
|
||||
});
|
||||
|
||||
it('maps reviewed document fields correctly', async () => {
|
||||
const doc = createDoc('doc-1');
|
||||
doc.approve('admin-1');
|
||||
mockDocRepo.findByPropertyId.mockResolvedValue([doc]);
|
||||
|
||||
const query = new GetPropertyDocumentsQuery('prop-1');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
const item = result[0]!;
|
||||
expect(item.status).toBe('APPROVED');
|
||||
expect(item.reviewedById).toBe('admin-1');
|
||||
expect(item.reviewedAt).toBeInstanceOf(Date);
|
||||
expect(item.rejectionReason).toBeNull();
|
||||
});
|
||||
|
||||
it('maps rejected document fields correctly', async () => {
|
||||
const doc = createDoc('doc-1');
|
||||
doc.reject('admin-1', 'Anh khong ro');
|
||||
mockDocRepo.findByPropertyId.mockResolvedValue([doc]);
|
||||
|
||||
const query = new GetPropertyDocumentsQuery('prop-1');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
const item = result[0]!;
|
||||
expect(item.status).toBe('REJECTED');
|
||||
expect(item.rejectionReason).toBe('Anh khong ro');
|
||||
expect(item.reviewedById).toBe('admin-1');
|
||||
expect(item.reviewedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('preserves null description in mapping', async () => {
|
||||
const doc = PropertyDocumentEntity.createNew(
|
||||
'doc-1', 'prop-1', 'user-1', 'SO_DO',
|
||||
'http://storage.local/documents/test.pdf',
|
||||
'doc.pdf', 'application/pdf', 1024,
|
||||
);
|
||||
mockDocRepo.findByPropertyId.mockResolvedValue([doc]);
|
||||
|
||||
const query = new GetPropertyDocumentsQuery('prop-1');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result[0]!.description).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { NotFoundException, ValidationException } from '@modules/shared';
|
||||
import { PropertyDocumentEntity } from '../../domain/entities/property-document.entity';
|
||||
import { type IPropertyDocumentRepository } from '../../domain/repositories/property-document.repository';
|
||||
import { RejectDocumentCommand } from '../commands/reject-document/reject-document.command';
|
||||
import { RejectDocumentHandler } from '../commands/reject-document/reject-document.handler';
|
||||
|
||||
describe('RejectDocumentHandler', () => {
|
||||
let handler: RejectDocumentHandler;
|
||||
let mockDocRepo: { [K in keyof IPropertyDocumentRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn>; debug: ReturnType<typeof vi.fn>; verbose: ReturnType<typeof vi.fn> };
|
||||
|
||||
const createPendingDoc = (id = 'doc-1') =>
|
||||
PropertyDocumentEntity.createNew(
|
||||
id, 'prop-1', 'user-1', 'SO_DO',
|
||||
'http://storage.local/documents/test.pdf',
|
||||
'sodo.pdf', 'application/pdf', 1024,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockDocRepo = {
|
||||
findById: vi.fn(),
|
||||
findByPropertyId: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn(),
|
||||
findPendingReview: vi.fn(),
|
||||
countApprovedByPropertyId: vi.fn(),
|
||||
};
|
||||
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
|
||||
|
||||
handler = new RejectDocumentHandler(
|
||||
mockDocRepo as any,
|
||||
mockLogger as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a pending document successfully', async () => {
|
||||
const doc = createPendingDoc();
|
||||
mockDocRepo.findById.mockResolvedValue(doc);
|
||||
|
||||
const command = new RejectDocumentCommand('doc-1', 'admin-1', 'Anh khong ro rang');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.documentId).toBe('doc-1');
|
||||
expect(result.status).toBe('REJECTED');
|
||||
expect(result.message).toContain('từ chối');
|
||||
expect(mockDocRepo.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('updates the document entity status to REJECTED with reason', async () => {
|
||||
const doc = createPendingDoc();
|
||||
mockDocRepo.findById.mockResolvedValue(doc);
|
||||
|
||||
const command = new RejectDocumentCommand('doc-1', 'admin-1', 'Giay to het han');
|
||||
await handler.execute(command);
|
||||
|
||||
const updatedDoc = mockDocRepo.update.mock.calls[0]![0];
|
||||
expect(updatedDoc.status).toBe('REJECTED');
|
||||
expect(updatedDoc.rejectionReason).toBe('Giay to het han');
|
||||
expect(updatedDoc.reviewedById).toBe('admin-1');
|
||||
expect(updatedDoc.reviewedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('throws NotFoundException when document does not exist', async () => {
|
||||
mockDocRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = new RejectDocumentCommand('nonexistent', 'admin-1', 'reason');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('throws ValidationException when document is not PENDING_REVIEW', async () => {
|
||||
const doc = createPendingDoc();
|
||||
doc.approve('admin-old'); // status becomes APPROVED
|
||||
mockDocRepo.findById.mockResolvedValue(doc);
|
||||
|
||||
const command = new RejectDocumentCommand('doc-1', 'admin-2', 'reason');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(ValidationException);
|
||||
await expect(handler.execute(command)).rejects.toThrow(/APPROVED/);
|
||||
});
|
||||
|
||||
it('throws ValidationException for already REJECTED document', async () => {
|
||||
const doc = createPendingDoc();
|
||||
doc.reject('admin-old', 'previous reason');
|
||||
mockDocRepo.findById.mockResolvedValue(doc);
|
||||
|
||||
const command = new RejectDocumentCommand('doc-1', 'admin-2', 'new reason');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(ValidationException);
|
||||
await expect(handler.execute(command)).rejects.toThrow(/REJECTED/);
|
||||
});
|
||||
|
||||
it('re-throws DomainException without wrapping', async () => {
|
||||
mockDocRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = new RejectDocumentCommand('doc-1', 'admin-1', 'reason');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(NotFoundException);
|
||||
await expect(handler.execute(command)).rejects.not.toThrow(InternalServerErrorException);
|
||||
});
|
||||
|
||||
it('wraps unexpected errors in InternalServerErrorException', async () => {
|
||||
mockDocRepo.findById.mockRejectedValue(new Error('DB timeout'));
|
||||
|
||||
const command = new RejectDocumentCommand('doc-1', 'admin-1', 'reason');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(InternalServerErrorException);
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stores the reason in the command', () => {
|
||||
const command = new RejectDocumentCommand('doc-1', 'admin-1', 'Giay to khong hop le');
|
||||
expect(command.reason).toBe('Giay to khong hop le');
|
||||
expect(command.documentId).toBe('doc-1');
|
||||
expect(command.adminId).toBe('admin-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
// Mock the @modules/listings barrel to prevent ListingsModule → AnalyticsModule → cockatiel
|
||||
// from being loaded during unit tests. The constants are all we need at runtime.
|
||||
vi.mock('@modules/listings', () => ({
|
||||
PROPERTY_REPOSITORY: 'PROPERTY_REPOSITORY',
|
||||
MEDIA_STORAGE_SERVICE: 'MEDIA_STORAGE_SERVICE',
|
||||
ListingsModule: class {},
|
||||
}));
|
||||
import { type IPropertyRepository } from '@modules/listings/domain/repositories/property.repository';
|
||||
import { type IMediaStorageService } from '@modules/listings/infrastructure/services/media-storage.service';
|
||||
import { NotFoundException, ValidationException } from '@modules/shared';
|
||||
import { type IPropertyDocumentRepository } from '../../domain/repositories/property-document.repository';
|
||||
import { UploadDocumentCommand } from '../commands/upload-document/upload-document.command';
|
||||
import { UploadDocumentHandler } from '../commands/upload-document/upload-document.handler';
|
||||
|
||||
describe('UploadDocumentHandler', () => {
|
||||
let handler: UploadDocumentHandler;
|
||||
let mockDocRepo: { [K in keyof IPropertyDocumentRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockPropertyRepo: { [K in keyof IPropertyRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockMediaStorage: { [K in keyof IMediaStorageService]: ReturnType<typeof vi.fn> };
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn>; debug: ReturnType<typeof vi.fn>; verbose: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockDocRepo = {
|
||||
findById: vi.fn(),
|
||||
findByPropertyId: vi.fn().mockResolvedValue([]),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findPendingReview: vi.fn(),
|
||||
countApprovedByPropertyId: vi.fn(),
|
||||
};
|
||||
|
||||
mockPropertyRepo = {
|
||||
findById: vi.fn().mockResolvedValue({ id: 'prop-1' }),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
addMedia: vi.fn(),
|
||||
findMediaByPropertyId: vi.fn(),
|
||||
deleteMedia: vi.fn(),
|
||||
countMediaByPropertyId: vi.fn(),
|
||||
updateMediaOrder: vi.fn(),
|
||||
};
|
||||
|
||||
mockMediaStorage = {
|
||||
upload: vi.fn().mockResolvedValue('http://storage.local/documents/prop-1/test.pdf'),
|
||||
delete: vi.fn(),
|
||||
getPresignedUploadUrl: vi.fn(),
|
||||
generatePresignedUpload: vi.fn(),
|
||||
getPublicUrl: vi.fn(),
|
||||
isTrustedUrl: vi.fn(),
|
||||
};
|
||||
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
|
||||
|
||||
handler = new UploadDocumentHandler(
|
||||
mockDocRepo as any,
|
||||
mockPropertyRepo as any,
|
||||
mockMediaStorage as any,
|
||||
mockLogger as any,
|
||||
);
|
||||
});
|
||||
|
||||
const makeCommand = (overrides?: Partial<ConstructorParameters<typeof UploadDocumentCommand>[0] & Record<string, unknown>>) =>
|
||||
new UploadDocumentCommand(
|
||||
overrides?.propertyId as string ?? 'prop-1',
|
||||
overrides?.userId as string ?? 'user-1',
|
||||
(overrides?.documentType as any) ?? 'SO_DO',
|
||||
overrides?.file as any ?? {
|
||||
buffer: Buffer.from('fake-pdf-content'),
|
||||
mimetype: 'application/pdf',
|
||||
originalname: 'sodo.pdf',
|
||||
size: 2048,
|
||||
},
|
||||
overrides?.description as string | undefined,
|
||||
);
|
||||
|
||||
it('uploads document successfully', async () => {
|
||||
const command = makeCommand();
|
||||
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.documentId).toBeDefined();
|
||||
expect(result.url).toBe('http://storage.local/documents/prop-1/test.pdf');
|
||||
expect(mockPropertyRepo.findById).toHaveBeenCalledWith('prop-1');
|
||||
expect(mockDocRepo.findByPropertyId).toHaveBeenCalledWith('prop-1');
|
||||
expect(mockMediaStorage.upload).toHaveBeenCalledWith(
|
||||
expect.any(Buffer), 'sodo.pdf', 'application/pdf', 'documents/prop-1',
|
||||
);
|
||||
expect(mockDocRepo.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uploads document with description', async () => {
|
||||
const command = makeCommand({ description: 'So do chinh chu' });
|
||||
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.documentId).toBeDefined();
|
||||
expect(result.url).toBe('http://storage.local/documents/prop-1/test.pdf');
|
||||
expect(mockDocRepo.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when property does not exist', async () => {
|
||||
mockPropertyRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = makeCommand({ propertyId: 'nonexistent' });
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('throws ValidationException when max documents limit reached', async () => {
|
||||
const existingDocs = Array.from({ length: 10 }, (_, i) => ({ id: `doc-${i}` }));
|
||||
mockDocRepo.findByPropertyId.mockResolvedValue(existingDocs);
|
||||
|
||||
const command = makeCommand();
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(ValidationException);
|
||||
await expect(handler.execute(command)).rejects.toThrow(/10/);
|
||||
});
|
||||
|
||||
it('throws ValidationException when media upload fails', async () => {
|
||||
mockMediaStorage.upload.mockRejectedValue(new Error('Storage unavailable'));
|
||||
|
||||
const command = makeCommand();
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(ValidationException);
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saves entity with correct fields after successful upload', async () => {
|
||||
const command = makeCommand({ description: 'Mo ta' });
|
||||
|
||||
await handler.execute(command);
|
||||
|
||||
const savedEntity = mockDocRepo.save.mock.calls[0]![0];
|
||||
expect(savedEntity.propertyId).toBe('prop-1');
|
||||
expect(savedEntity.uploadedById).toBe('user-1');
|
||||
expect(savedEntity.documentType).toBe('SO_DO');
|
||||
expect(savedEntity.status).toBe('PENDING_REVIEW');
|
||||
expect(savedEntity.url).toBe('http://storage.local/documents/prop-1/test.pdf');
|
||||
expect(savedEntity.fileName).toBe('sodo.pdf');
|
||||
expect(savedEntity.mimeType).toBe('application/pdf');
|
||||
expect(savedEntity.fileSizeBytes).toBe(2048);
|
||||
expect(savedEntity.description).toBe('Mo ta');
|
||||
expect(savedEntity.rejectionReason).toBeNull();
|
||||
expect(savedEntity.reviewedById).toBeNull();
|
||||
expect(savedEntity.reviewedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('re-throws DomainException without wrapping', async () => {
|
||||
mockPropertyRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = makeCommand();
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(NotFoundException);
|
||||
// Should NOT throw InternalServerErrorException
|
||||
await expect(handler.execute(command)).rejects.not.toThrow(InternalServerErrorException);
|
||||
});
|
||||
|
||||
it('wraps unexpected errors in InternalServerErrorException', async () => {
|
||||
mockPropertyRepo.findById.mockRejectedValue(new Error('DB connection lost'));
|
||||
|
||||
const command = makeCommand();
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(InternalServerErrorException);
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows upload when under document limit', async () => {
|
||||
const existingDocs = Array.from({ length: 9 }, (_, i) => ({ id: `doc-${i}` }));
|
||||
mockDocRepo.findByPropertyId.mockResolvedValue(existingDocs);
|
||||
|
||||
const command = makeCommand();
|
||||
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.documentId).toBeDefined();
|
||||
expect(mockDocRepo.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
export class ApproveDocumentCommand {
|
||||
constructor(
|
||||
public readonly documentId: string,
|
||||
public readonly adminId: string,
|
||||
public readonly notes?: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, LoggerService, NotFoundException, PrismaService, ValidationException } from '@modules/shared';
|
||||
import { PROPERTY_DOCUMENT_REPOSITORY, type IPropertyDocumentRepository } from '../../../domain/repositories/property-document.repository';
|
||||
import { ApproveDocumentCommand } from './approve-document.command';
|
||||
|
||||
export interface ApproveDocumentResult {
|
||||
documentId: string;
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@CommandHandler(ApproveDocumentCommand)
|
||||
export class ApproveDocumentHandler implements ICommandHandler<ApproveDocumentCommand> {
|
||||
constructor(
|
||||
@Inject(PROPERTY_DOCUMENT_REPOSITORY) private readonly docRepo: IPropertyDocumentRepository,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(command: ApproveDocumentCommand): Promise<ApproveDocumentResult> {
|
||||
try {
|
||||
const doc = await this.docRepo.findById(command.documentId);
|
||||
if (!doc) {
|
||||
throw new NotFoundException('Giấy tờ pháp lý', command.documentId);
|
||||
}
|
||||
|
||||
if (doc.status !== 'PENDING_REVIEW') {
|
||||
throw new ValidationException(
|
||||
`Giấy tờ đang ở trạng thái ${doc.status}, chỉ có thể duyệt giấy tờ đang chờ duyệt`,
|
||||
{ currentStatus: doc.status },
|
||||
);
|
||||
}
|
||||
|
||||
doc.approve(command.adminId);
|
||||
await this.docRepo.update(doc);
|
||||
|
||||
// Set certificateVerified on the property
|
||||
await this.prisma.property.update({
|
||||
where: { id: doc.propertyId },
|
||||
data: { certificateVerified: true },
|
||||
});
|
||||
|
||||
return {
|
||||
documentId: doc.id,
|
||||
status: 'APPROVED',
|
||||
message: 'Giấy tờ pháp lý đã được xác minh thành công',
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to approve document ${command.documentId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
'ApproveDocumentHandler',
|
||||
);
|
||||
throw new InternalServerErrorException('Lỗi khi duyệt giấy tờ pháp lý');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class RejectDocumentCommand {
|
||||
constructor(
|
||||
public readonly documentId: string,
|
||||
public readonly adminId: string,
|
||||
public readonly reason: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, LoggerService, NotFoundException, ValidationException } from '@modules/shared';
|
||||
import { PROPERTY_DOCUMENT_REPOSITORY, type IPropertyDocumentRepository } from '../../../domain/repositories/property-document.repository';
|
||||
import { RejectDocumentCommand } from './reject-document.command';
|
||||
|
||||
export interface RejectDocumentResult {
|
||||
documentId: string;
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@CommandHandler(RejectDocumentCommand)
|
||||
export class RejectDocumentHandler implements ICommandHandler<RejectDocumentCommand> {
|
||||
constructor(
|
||||
@Inject(PROPERTY_DOCUMENT_REPOSITORY) private readonly docRepo: IPropertyDocumentRepository,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(command: RejectDocumentCommand): Promise<RejectDocumentResult> {
|
||||
try {
|
||||
const doc = await this.docRepo.findById(command.documentId);
|
||||
if (!doc) {
|
||||
throw new NotFoundException('Giấy tờ pháp lý', command.documentId);
|
||||
}
|
||||
|
||||
if (doc.status !== 'PENDING_REVIEW') {
|
||||
throw new ValidationException(
|
||||
`Giấy tờ đang ở trạng thái ${doc.status}, chỉ có thể từ chối giấy tờ đang chờ duyệt`,
|
||||
{ currentStatus: doc.status },
|
||||
);
|
||||
}
|
||||
|
||||
doc.reject(command.adminId, command.reason);
|
||||
await this.docRepo.update(doc);
|
||||
|
||||
return {
|
||||
documentId: doc.id,
|
||||
status: 'REJECTED',
|
||||
message: 'Giấy tờ pháp lý đã bị từ chối',
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to reject document ${command.documentId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
'RejectDocumentHandler',
|
||||
);
|
||||
throw new InternalServerErrorException('Lỗi khi từ chối giấy tờ pháp lý');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { type DocumentType } from '../../../domain/entities/property-document.entity';
|
||||
|
||||
export class UploadDocumentCommand {
|
||||
constructor(
|
||||
public readonly propertyId: string,
|
||||
public readonly userId: string,
|
||||
public readonly documentType: DocumentType,
|
||||
public readonly file: {
|
||||
buffer: Buffer;
|
||||
mimetype: string;
|
||||
originalname: string;
|
||||
size: number;
|
||||
},
|
||||
public readonly description?: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { PROPERTY_REPOSITORY, type IPropertyRepository, MEDIA_STORAGE_SERVICE, type IMediaStorageService } from '@modules/listings';
|
||||
import { DomainException, LoggerService, NotFoundException, ValidationException } from '@modules/shared';
|
||||
import { PropertyDocumentEntity } from '../../../domain/entities/property-document.entity';
|
||||
import { PROPERTY_DOCUMENT_REPOSITORY, type IPropertyDocumentRepository } from '../../../domain/repositories/property-document.repository';
|
||||
import { UploadDocumentCommand } from './upload-document.command';
|
||||
|
||||
const MAX_DOCUMENTS_PER_PROPERTY = 10;
|
||||
|
||||
export interface UploadDocumentResult {
|
||||
documentId: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
@CommandHandler(UploadDocumentCommand)
|
||||
export class UploadDocumentHandler implements ICommandHandler<UploadDocumentCommand> {
|
||||
constructor(
|
||||
@Inject(PROPERTY_DOCUMENT_REPOSITORY) private readonly docRepo: IPropertyDocumentRepository,
|
||||
@Inject(PROPERTY_REPOSITORY) private readonly propertyRepo: IPropertyRepository,
|
||||
@Inject(MEDIA_STORAGE_SERVICE) private readonly mediaStorage: IMediaStorageService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(command: UploadDocumentCommand): Promise<UploadDocumentResult> {
|
||||
try {
|
||||
const property = await this.propertyRepo.findById(command.propertyId);
|
||||
if (!property) {
|
||||
throw new NotFoundException('Bất động sản', command.propertyId);
|
||||
}
|
||||
|
||||
const existing = await this.docRepo.findByPropertyId(command.propertyId);
|
||||
if (existing.length >= MAX_DOCUMENTS_PER_PROPERTY) {
|
||||
throw new ValidationException(`Tối đa ${MAX_DOCUMENTS_PER_PROPERTY} giấy tờ pháp lý cho mỗi bất động sản`);
|
||||
}
|
||||
|
||||
let url: string;
|
||||
try {
|
||||
url = await this.mediaStorage.upload(
|
||||
command.file.buffer,
|
||||
command.file.originalname,
|
||||
command.file.mimetype,
|
||||
`documents/${command.propertyId}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Document upload failed for property ${command.propertyId}: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
'UploadDocumentHandler',
|
||||
);
|
||||
throw new ValidationException('Tải lên giấy tờ thất bại, vui lòng thử lại');
|
||||
}
|
||||
|
||||
const documentId = createId();
|
||||
const doc = PropertyDocumentEntity.createNew(
|
||||
documentId,
|
||||
command.propertyId,
|
||||
command.userId,
|
||||
command.documentType,
|
||||
url,
|
||||
command.file.originalname,
|
||||
command.file.mimetype,
|
||||
command.file.size,
|
||||
command.description,
|
||||
);
|
||||
|
||||
await this.docRepo.save(doc);
|
||||
|
||||
return { documentId, url };
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to upload document for property ${command.propertyId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException('Không thể tải lên giấy tờ pháp lý');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { PROPERTY_DOCUMENT_REPOSITORY, type IPropertyDocumentRepository } from '../../../domain/repositories/property-document.repository';
|
||||
import { type PropertyDocumentDto } from '../get-property-documents/get-property-documents.handler';
|
||||
import { GetPendingDocumentsQuery } from './get-pending-documents.query';
|
||||
|
||||
export interface PendingDocumentsResult {
|
||||
items: PropertyDocumentDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
@QueryHandler(GetPendingDocumentsQuery)
|
||||
export class GetPendingDocumentsHandler implements IQueryHandler<GetPendingDocumentsQuery> {
|
||||
constructor(
|
||||
@Inject(PROPERTY_DOCUMENT_REPOSITORY) private readonly docRepo: IPropertyDocumentRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetPendingDocumentsQuery): Promise<PendingDocumentsResult> {
|
||||
const { items, total } = await this.docRepo.findPendingReview(query.page, query.limit);
|
||||
return {
|
||||
items: items.map((d) => ({
|
||||
id: d.id,
|
||||
propertyId: d.propertyId,
|
||||
uploadedById: d.uploadedById,
|
||||
documentType: d.documentType,
|
||||
status: d.status,
|
||||
url: d.url,
|
||||
fileName: d.fileName,
|
||||
mimeType: d.mimeType,
|
||||
fileSizeBytes: d.fileSizeBytes,
|
||||
description: d.description,
|
||||
rejectionReason: d.rejectionReason,
|
||||
reviewedById: d.reviewedById,
|
||||
reviewedAt: d.reviewedAt,
|
||||
createdAt: d.createdAt,
|
||||
})),
|
||||
total,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class GetPendingDocumentsQuery {
|
||||
constructor(
|
||||
public readonly page: number,
|
||||
public readonly limit: number,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { PROPERTY_DOCUMENT_REPOSITORY, type IPropertyDocumentRepository } from '../../../domain/repositories/property-document.repository';
|
||||
import { GetPropertyDocumentsQuery } from './get-property-documents.query';
|
||||
|
||||
export interface PropertyDocumentDto {
|
||||
id: string;
|
||||
propertyId: string;
|
||||
uploadedById: string;
|
||||
documentType: string;
|
||||
status: string;
|
||||
url: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSizeBytes: number;
|
||||
description: string | null;
|
||||
rejectionReason: string | null;
|
||||
reviewedById: string | null;
|
||||
reviewedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@QueryHandler(GetPropertyDocumentsQuery)
|
||||
export class GetPropertyDocumentsHandler implements IQueryHandler<GetPropertyDocumentsQuery> {
|
||||
constructor(
|
||||
@Inject(PROPERTY_DOCUMENT_REPOSITORY) private readonly docRepo: IPropertyDocumentRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetPropertyDocumentsQuery): Promise<PropertyDocumentDto[]> {
|
||||
const docs = await this.docRepo.findByPropertyId(query.propertyId);
|
||||
return docs.map((d) => ({
|
||||
id: d.id,
|
||||
propertyId: d.propertyId,
|
||||
uploadedById: d.uploadedById,
|
||||
documentType: d.documentType,
|
||||
status: d.status,
|
||||
url: d.url,
|
||||
fileName: d.fileName,
|
||||
mimeType: d.mimeType,
|
||||
fileSizeBytes: d.fileSizeBytes,
|
||||
description: d.description,
|
||||
rejectionReason: d.rejectionReason,
|
||||
reviewedById: d.reviewedById,
|
||||
reviewedAt: d.reviewedAt,
|
||||
createdAt: d.createdAt,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class GetPropertyDocumentsQuery {
|
||||
constructor(
|
||||
public readonly propertyId: string,
|
||||
) {}
|
||||
}
|
||||
47
apps/api/src/modules/documents/documents.module.ts
Normal file
47
apps/api/src/modules/documents/documents.module.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
import { ListingsModule, MEDIA_STORAGE_SERVICE, MinioMediaStorageService } from '@modules/listings';
|
||||
import { ApproveDocumentHandler } from './application/commands/approve-document/approve-document.handler';
|
||||
import { RejectDocumentHandler } from './application/commands/reject-document/reject-document.handler';
|
||||
import { UploadDocumentHandler } from './application/commands/upload-document/upload-document.handler';
|
||||
import { GetPendingDocumentsHandler } from './application/queries/get-pending-documents/get-pending-documents.handler';
|
||||
import { GetPropertyDocumentsHandler } from './application/queries/get-property-documents/get-property-documents.handler';
|
||||
import { PROPERTY_DOCUMENT_REPOSITORY } from './domain/repositories/property-document.repository';
|
||||
import { PrismaPropertyDocumentRepository } from './infrastructure/repositories/prisma-property-document.repository';
|
||||
import { PropertyDocumentsController } from './presentation/controllers/property-documents.controller';
|
||||
|
||||
const CommandHandlers = [
|
||||
UploadDocumentHandler,
|
||||
ApproveDocumentHandler,
|
||||
RejectDocumentHandler,
|
||||
];
|
||||
|
||||
const QueryHandlers = [
|
||||
GetPropertyDocumentsHandler,
|
||||
GetPendingDocumentsHandler,
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
CqrsModule,
|
||||
ListingsModule,
|
||||
MulterModule.register({
|
||||
limits: { fileSize: 20 * 1024 * 1024 }, // 20 MB
|
||||
}),
|
||||
],
|
||||
controllers: [PropertyDocumentsController],
|
||||
providers: [
|
||||
// Repositories
|
||||
{ provide: PROPERTY_DOCUMENT_REPOSITORY, useClass: PrismaPropertyDocumentRepository },
|
||||
|
||||
// Storage (reuse MinIO implementation)
|
||||
{ provide: MEDIA_STORAGE_SERVICE, useClass: MinioMediaStorageService },
|
||||
|
||||
// CQRS
|
||||
...CommandHandlers,
|
||||
...QueryHandlers,
|
||||
],
|
||||
exports: [PROPERTY_DOCUMENT_REPOSITORY],
|
||||
})
|
||||
export class DocumentsModule {}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PropertyDocumentEntity } from '../entities/property-document.entity';
|
||||
|
||||
describe('PropertyDocumentEntity', () => {
|
||||
const defaultProps = {
|
||||
propertyId: 'prop-1',
|
||||
uploadedById: 'user-1',
|
||||
documentType: 'SO_DO' as const,
|
||||
status: 'PENDING_REVIEW' as const,
|
||||
url: 'http://storage.local/documents/test.pdf',
|
||||
fileName: 'sodo.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
fileSizeBytes: 1024,
|
||||
description: 'So do chinh chu',
|
||||
rejectionReason: null,
|
||||
reviewedById: null,
|
||||
reviewedAt: null,
|
||||
};
|
||||
|
||||
const now = new Date('2026-04-01T10:00:00Z');
|
||||
const later = new Date('2026-04-01T10:05:00Z');
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create entity with all properties', () => {
|
||||
const entity = new PropertyDocumentEntity('doc-1', defaultProps, now, later);
|
||||
|
||||
expect(entity.id).toBe('doc-1');
|
||||
expect(entity.propertyId).toBe('prop-1');
|
||||
expect(entity.uploadedById).toBe('user-1');
|
||||
expect(entity.documentType).toBe('SO_DO');
|
||||
expect(entity.status).toBe('PENDING_REVIEW');
|
||||
expect(entity.url).toBe('http://storage.local/documents/test.pdf');
|
||||
expect(entity.fileName).toBe('sodo.pdf');
|
||||
expect(entity.mimeType).toBe('application/pdf');
|
||||
expect(entity.fileSizeBytes).toBe(1024);
|
||||
expect(entity.description).toBe('So do chinh chu');
|
||||
expect(entity.rejectionReason).toBeNull();
|
||||
expect(entity.reviewedById).toBeNull();
|
||||
expect(entity.reviewedAt).toBeNull();
|
||||
expect(entity.createdAt).toEqual(now);
|
||||
expect(entity.updatedAt).toEqual(later);
|
||||
});
|
||||
|
||||
it('should default createdAt and updatedAt when not provided', () => {
|
||||
const before = new Date();
|
||||
const entity = new PropertyDocumentEntity('doc-1', defaultProps);
|
||||
const after = new Date();
|
||||
|
||||
expect(entity.createdAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
||||
expect(entity.createdAt.getTime()).toBeLessThanOrEqual(after.getTime());
|
||||
expect(entity.updatedAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
||||
expect(entity.updatedAt.getTime()).toBeLessThanOrEqual(after.getTime());
|
||||
});
|
||||
|
||||
it('should handle null description', () => {
|
||||
const entity = new PropertyDocumentEntity('doc-1', {
|
||||
...defaultProps,
|
||||
description: null,
|
||||
});
|
||||
|
||||
expect(entity.description).toBeNull();
|
||||
});
|
||||
|
||||
it('should store all document types correctly', () => {
|
||||
const types = ['SO_DO', 'SO_HONG', 'GCNQSD', 'OTHER'] as const;
|
||||
for (const docType of types) {
|
||||
const entity = new PropertyDocumentEntity('doc-1', {
|
||||
...defaultProps,
|
||||
documentType: docType,
|
||||
});
|
||||
expect(entity.documentType).toBe(docType);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNew', () => {
|
||||
it('should create a new document with PENDING_REVIEW status', () => {
|
||||
const entity = PropertyDocumentEntity.createNew(
|
||||
'doc-1',
|
||||
'prop-1',
|
||||
'user-1',
|
||||
'SO_DO',
|
||||
'http://storage.local/documents/test.pdf',
|
||||
'sodo.pdf',
|
||||
'application/pdf',
|
||||
2048,
|
||||
'Mo ta',
|
||||
);
|
||||
|
||||
expect(entity.id).toBe('doc-1');
|
||||
expect(entity.propertyId).toBe('prop-1');
|
||||
expect(entity.uploadedById).toBe('user-1');
|
||||
expect(entity.documentType).toBe('SO_DO');
|
||||
expect(entity.status).toBe('PENDING_REVIEW');
|
||||
expect(entity.url).toBe('http://storage.local/documents/test.pdf');
|
||||
expect(entity.fileName).toBe('sodo.pdf');
|
||||
expect(entity.mimeType).toBe('application/pdf');
|
||||
expect(entity.fileSizeBytes).toBe(2048);
|
||||
expect(entity.description).toBe('Mo ta');
|
||||
expect(entity.rejectionReason).toBeNull();
|
||||
expect(entity.reviewedById).toBeNull();
|
||||
expect(entity.reviewedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should set description to null when not provided', () => {
|
||||
const entity = PropertyDocumentEntity.createNew(
|
||||
'doc-1',
|
||||
'prop-1',
|
||||
'user-1',
|
||||
'SO_HONG',
|
||||
'http://storage.local/documents/test.pdf',
|
||||
'sohong.pdf',
|
||||
'application/pdf',
|
||||
1024,
|
||||
);
|
||||
|
||||
expect(entity.description).toBeNull();
|
||||
});
|
||||
|
||||
it('should set description to null when undefined is passed', () => {
|
||||
const entity = PropertyDocumentEntity.createNew(
|
||||
'doc-1',
|
||||
'prop-1',
|
||||
'user-1',
|
||||
'GCNQSD',
|
||||
'http://storage.local/documents/test.pdf',
|
||||
'gcnqsd.pdf',
|
||||
'image/jpeg',
|
||||
512,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(entity.description).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('approve', () => {
|
||||
it('should set status to APPROVED', () => {
|
||||
const entity = PropertyDocumentEntity.createNew(
|
||||
'doc-1', 'prop-1', 'user-1', 'SO_DO',
|
||||
'http://storage.local/test.pdf', 'test.pdf', 'application/pdf', 1024,
|
||||
);
|
||||
|
||||
entity.approve('admin-1');
|
||||
|
||||
expect(entity.status).toBe('APPROVED');
|
||||
});
|
||||
|
||||
it('should set reviewedById to the reviewer', () => {
|
||||
const entity = PropertyDocumentEntity.createNew(
|
||||
'doc-1', 'prop-1', 'user-1', 'SO_DO',
|
||||
'http://storage.local/test.pdf', 'test.pdf', 'application/pdf', 1024,
|
||||
);
|
||||
|
||||
entity.approve('admin-42');
|
||||
|
||||
expect(entity.reviewedById).toBe('admin-42');
|
||||
});
|
||||
|
||||
it('should set reviewedAt to current time', () => {
|
||||
const entity = PropertyDocumentEntity.createNew(
|
||||
'doc-1', 'prop-1', 'user-1', 'SO_DO',
|
||||
'http://storage.local/test.pdf', 'test.pdf', 'application/pdf', 1024,
|
||||
);
|
||||
|
||||
const before = new Date();
|
||||
entity.approve('admin-1');
|
||||
const after = new Date();
|
||||
|
||||
expect(entity.reviewedAt).not.toBeNull();
|
||||
expect(entity.reviewedAt!.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
||||
expect(entity.reviewedAt!.getTime()).toBeLessThanOrEqual(after.getTime());
|
||||
});
|
||||
|
||||
it('should clear rejectionReason on approval', () => {
|
||||
const entity = new PropertyDocumentEntity('doc-1', {
|
||||
...defaultProps,
|
||||
status: 'REJECTED',
|
||||
rejectionReason: 'Anh khong ro',
|
||||
reviewedById: 'admin-old',
|
||||
reviewedAt: new Date('2026-01-01'),
|
||||
});
|
||||
|
||||
entity.approve('admin-2');
|
||||
|
||||
expect(entity.status).toBe('APPROVED');
|
||||
expect(entity.rejectionReason).toBeNull();
|
||||
expect(entity.reviewedById).toBe('admin-2');
|
||||
});
|
||||
|
||||
it('should update updatedAt timestamp', () => {
|
||||
const entity = new PropertyDocumentEntity('doc-1', defaultProps, now, now);
|
||||
|
||||
entity.approve('admin-1');
|
||||
|
||||
expect(entity.updatedAt.getTime()).toBeGreaterThanOrEqual(now.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('reject', () => {
|
||||
it('should set status to REJECTED', () => {
|
||||
const entity = PropertyDocumentEntity.createNew(
|
||||
'doc-1', 'prop-1', 'user-1', 'SO_DO',
|
||||
'http://storage.local/test.pdf', 'test.pdf', 'application/pdf', 1024,
|
||||
);
|
||||
|
||||
entity.reject('admin-1', 'Anh khong ro rang');
|
||||
|
||||
expect(entity.status).toBe('REJECTED');
|
||||
});
|
||||
|
||||
it('should set rejectionReason', () => {
|
||||
const entity = PropertyDocumentEntity.createNew(
|
||||
'doc-1', 'prop-1', 'user-1', 'SO_DO',
|
||||
'http://storage.local/test.pdf', 'test.pdf', 'application/pdf', 1024,
|
||||
);
|
||||
|
||||
entity.reject('admin-1', 'Giay to het han');
|
||||
|
||||
expect(entity.rejectionReason).toBe('Giay to het han');
|
||||
});
|
||||
|
||||
it('should set reviewedById to the reviewer', () => {
|
||||
const entity = PropertyDocumentEntity.createNew(
|
||||
'doc-1', 'prop-1', 'user-1', 'SO_DO',
|
||||
'http://storage.local/test.pdf', 'test.pdf', 'application/pdf', 1024,
|
||||
);
|
||||
|
||||
entity.reject('admin-99', 'reason');
|
||||
|
||||
expect(entity.reviewedById).toBe('admin-99');
|
||||
});
|
||||
|
||||
it('should set reviewedAt to current time', () => {
|
||||
const entity = PropertyDocumentEntity.createNew(
|
||||
'doc-1', 'prop-1', 'user-1', 'SO_DO',
|
||||
'http://storage.local/test.pdf', 'test.pdf', 'application/pdf', 1024,
|
||||
);
|
||||
|
||||
const before = new Date();
|
||||
entity.reject('admin-1', 'reason');
|
||||
const after = new Date();
|
||||
|
||||
expect(entity.reviewedAt).not.toBeNull();
|
||||
expect(entity.reviewedAt!.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
||||
expect(entity.reviewedAt!.getTime()).toBeLessThanOrEqual(after.getTime());
|
||||
});
|
||||
|
||||
it('should update updatedAt timestamp', () => {
|
||||
const entity = new PropertyDocumentEntity('doc-1', defaultProps, now, now);
|
||||
|
||||
entity.reject('admin-1', 'reason');
|
||||
|
||||
expect(entity.updatedAt.getTime()).toBeGreaterThanOrEqual(now.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals (BaseEntity)', () => {
|
||||
it('should return true for same id', () => {
|
||||
const a = new PropertyDocumentEntity('doc-1', defaultProps);
|
||||
const b = new PropertyDocumentEntity('doc-1', { ...defaultProps, fileName: 'other.pdf' });
|
||||
|
||||
expect(a.equals(b)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different id', () => {
|
||||
const a = new PropertyDocumentEntity('doc-1', defaultProps);
|
||||
const b = new PropertyDocumentEntity('doc-2', defaultProps);
|
||||
|
||||
expect(a.equals(b)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when comparing to itself', () => {
|
||||
const a = new PropertyDocumentEntity('doc-1', defaultProps);
|
||||
|
||||
expect(a.equals(a)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
1
apps/api/src/modules/documents/domain/entities/index.ts
Normal file
1
apps/api/src/modules/documents/domain/entities/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PropertyDocumentEntity, type PropertyDocumentProps, type DocumentType, type DocumentVerificationStatus } from './property-document.entity';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user