Compare commits

..

5 Commits

Author SHA1 Message Date
Ho Ngoc Hai
9e70074403 refactor(api): migrate all raw Error throws to DomainException (GOO-53)
Replace 14 raw `throw new Error(...)` instances in shared/, analytics/,
payments/ and listings/ with typed DomainException subclasses:

- env-validation.ts: INTERNAL_ERROR (3 startup config guards)
- field-encryption.ts: INTERNAL_ERROR / VALIDATION_FAILED (2 instances)
- result.ts: INTERNAL_ERROR (unwrapErr misuse guard)
- ai-service.client.ts: AI_PROVIDER_ERROR (HTTP error from AI service)
- prisma-avm.service.ts: VALIDATION_FAILED + NotFoundException (2 instances)
- avm-retrain-cron.service.ts: AI_PROVIDER_ERROR (2 upload/retrain failures)
- zalopay.service.ts: PAYMENT_FAILED (gateway failure)
- momo.service.ts: PAYMENT_FAILED (gateway failure)
- media-storage.service.ts: INTERNAL_ERROR (missing env var)

Update prisma-avm spec to match new NotFoundException message format.
All 6 PrismaAVMService tests and 31 shared domain tests pass.
Pre-existing failures in admin/industrial/mcp modules are unrelated.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-23 20:12:48 +07:00
Ho Ngoc Hai
54c97daa5b fix(auth): migrate raw Error throws to DomainException (GOO-52)
Replace 9 raw `throw new Error(...)` instances in the auth module with
typed DomainException subclasses so the GlobalExceptionFilter produces
structured error responses with proper HTTP status codes and error codes.

- zalo-oauth.strategy.ts (4): AUTH_OAUTH_PROVIDER_ERROR / 502
- jwt.strategy.ts (1): AUTH_CONFIG_MISSING / 500
- auth.module.ts (1): AUTH_CONFIG_MISSING / 500
- oauth.service.ts (3): AUTH_ACCOUNT_DISABLED / 403
- Add AUTH_OAUTH_PROVIDER_ERROR, AUTH_CONFIG_MISSING, AUTH_ACCOUNT_DISABLED
  to ErrorCode enum
- Update jwt.strategy.spec.ts mock to export DomainException + ErrorCode

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-23 19:58:31 +07:00
Ho Ngoc Hai
cc851d7e5f Sync PROJECT_TRACKER.md với Paperclip issue statuses
Updated docs/PROJECT_TRACKER.md to reflect current state from Paperclip API:
- GOO-2 section now shows 42 done issues (was 1/32)
- Added comprehensive audit phase tracking (GOO-47 through GOO-65)
- Updated progress: 42/65 done (65%), 16 in_progress, 5 todo, 2 blocked
- Preserved all legacy phases (TEC-0 through Wave 14 of Giai Đoạn 7)
- Added detailed breakdown by audit category (A1-A11 unit tests, B1-B8 infrastructure)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-23 19:52:54 +07:00
Ho Ngoc Hai
e706f4ddf2 test(reference): add unit tests for VnAdmin service and controller (GOO-59)
Add 29 unit tests covering VnAdminService (listProvinces, listDistricts,
listWards, resolveDistrict, isKnownDistrictName) and VnAdminController
(all endpoints + public access verification). 100% coverage on all metrics.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-23 10:49:30 +07:00
Ho Ngoc Hai
0924f0cb9b test(web): add middleware + i18n + messages test suites (GOO-60)
Cover frontend middleware auth/locale logic, i18n config/routing/request/navigation,
and vi/en message parity — 91 new tests across 6 files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 10:47:30 +07:00
511 changed files with 4116 additions and 26525 deletions

View File

@@ -32,19 +32,6 @@ REDIS_PORT=6379
REDIS_PASSWORD=CHANGE_ME_IN_PRODUCTION
REDIS_URL=redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}
# -----------------------------------------------------------------------------
# Redis — Queue (BullMQ)
#
# RFC-004 Phase 3: the async backbone (BullMQ) can point at a Redis instance
# separate from cache / throttler / websocket to keep hot cache traffic from
# starving queue operations. If unset, queue traffic falls back to the cache
# REDIS_* vars above (single-instance dev and small deployments keep working
# unchanged).
# -----------------------------------------------------------------------------
# REDIS_QUEUE_HOST=
# REDIS_QUEUE_PORT=
# REDIS_QUEUE_PASSWORD=
# -----------------------------------------------------------------------------
# Typesense
# -----------------------------------------------------------------------------
@@ -91,15 +78,6 @@ JWT_EXPIRES_IN=15m
JWT_REFRESH_SECRET=<generate with: openssl rand -base64 48>
JWT_REFRESH_EXPIRES_IN=7d
# -----------------------------------------------------------------------------
# Seed / E2E Accounts
# -----------------------------------------------------------------------------
# Required when running `pnpm db:seed`. Use a local/test-only value.
# Do not reuse this password for any real production admin account.
SEED_DEFAULT_PASSWORD=
BCRYPT_ROUNDS=12
E2E_ADMIN_PHONE=0876677771
# -----------------------------------------------------------------------------
# OAuth Providers
# -----------------------------------------------------------------------------
@@ -119,19 +97,11 @@ FRONTEND_URL=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:3000
WEB_PORT=3001
# Demo accounts must stay disabled in production. To enable in a local demo,
# provide a JSON array of {phone,name,role,badgeClass} and a temporary password.
NEXT_PUBLIC_ENABLE_DEMO_ACCOUNTS=false
NEXT_PUBLIC_DEMO_PASSWORD=
NEXT_PUBLIC_DEMO_ACCOUNTS=
# -----------------------------------------------------------------------------
# AI Service (Python/FastAPI)
# -----------------------------------------------------------------------------
AI_SERVICE_PORT=8000
AI_SERVICE_URL=http://localhost:8000
AI_SERVICE_API_KEY=<optional-in-dev-required-in-prod>
AI_CORS_ORIGINS=http://localhost:3000,http://localhost:3001
CLAUDE_API_KEY=
# -----------------------------------------------------------------------------
@@ -238,10 +208,7 @@ SENTRY_PROJECT=
# Must be exactly 64 hex characters (32 bytes).
# openssl rand -hex 32
# -----------------------------------------------------------------------------
FIELD_ENCRYPTION_KEY=<generate with: openssl rand -hex 32>
FIELD_ENCRYPTION_KEY_VERSION=1
# Backward-compatible fallback accepted by the API; prefer FIELD_ENCRYPTION_KEY.
KYC_ENCRYPTION_KEY=
KYC_ENCRYPTION_KEY=<generate with: openssl rand -hex 32>
KYC_ENCRYPTION_KEY_VERSION=1
# -----------------------------------------------------------------------------

View File

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

View File

@@ -64,8 +64,24 @@ jobs:
- name: Typecheck
run: pnpm typecheck
- name: Test
run: pnpm test
- name: Test with coverage
run: pnpm test:coverage
- name: Upload API coverage report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: coverage-api
path: apps/api/coverage/
retention-days: 14
- name: Upload Web coverage report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: coverage-web
path: apps/web/coverage/
retention-days: 14
- name: Build
run: pnpm build
@@ -145,14 +161,106 @@ jobs:
print("OpenAPI paths OK:", sorted(paths.keys()))
PY
- name: Setup Node.js for contract drift check
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm for contract drift check
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install JS deps for contract drift check
working-directory: .
run: pnpm install --frozen-lockfile
- name: AI sidecar OpenAPI contract drift check
env:
AI_CORS_ORIGINS: http://localhost:3000
# Tell the drift script which Python interpreter to use; libs/ai-services
# has no .venv in CI, so we rely on the system python where deps are installed.
PYTHON: python
working-directory: .
run: node libs/ai-contract/scripts/check-drift.mjs
e2e:
name: E2E Tests
needs: ci
runs-on: ubuntu-latest
timeout-minutes: 45
timeout-minutes: 20
services:
postgres:
image: postgis/postgis:16-3.4
env:
POSTGRES_DB: goodgo_test
POSTGRES_USER: goodgo
POSTGRES_PASSWORD: goodgo_test_secret
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U goodgo -d goodgo_test"
--health-interval 10s
--health-timeout 5s
--health-retries 5
--health-start-period 30s
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
typesense:
image: typesense/typesense:27.1
ports:
- 8108:8108
env:
TYPESENSE_API_KEY: ts_ci_key
TYPESENSE_DATA_DIR: /data
options: >-
--health-cmd "curl -sf http://localhost:8108/health || exit 1"
--health-interval 10s
--health-timeout 5s
--health-retries 5
minio:
image: minio/minio:latest
ports:
- 9000:9000
env:
MINIO_ROOT_USER: ci_minio_user
MINIO_ROOT_PASSWORD: ci_minio_secret_key_32chars!!
options: >-
--health-cmd "curl -sf http://localhost:9000/minio/health/live || exit 1"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
CI: true
DATABASE_URL: postgresql://goodgo:goodgo_test_secret@localhost:5432/goodgo_test
REDIS_URL: redis://localhost:6379
TYPESENSE_URL: http://localhost:8108
TYPESENSE_HOST: localhost
TYPESENSE_PORT: 8108
TYPESENSE_API_KEY: ts_ci_key
MINIO_ENDPOINT: localhost
MINIO_PORT: 9000
MINIO_ACCESS_KEY: ci_minio_user
MINIO_SECRET_KEY: ci_minio_secret_key_32chars!!
MINIO_BUCKET: goodgo-uploads
NODE_ENV: test
JWT_SECRET: e2e-test-jwt-secret-key
JWT_REFRESH_SECRET: e2e-test-refresh-secret-key
VNPAY_TMN_CODE: TESTCODE
VNPAY_HASH_SECRET: TESTHASHSECRET
VNPAY_URL: https://sandbox.vnpayment.vn/paymentv2/vpcpay.html
VNPAY_RETURN_URL: http://localhost:3000/payment/return
steps:
- name: Checkout
@@ -170,12 +278,6 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Load E2E environment
run: awk 'NF && $1 !~ /^#/' .env.test >> "$GITHUB_ENV"
- name: Start CI service stack
run: docker compose --env-file .env.ci -f docker-compose.ci.yml up -d --wait
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
@@ -218,7 +320,3 @@ jobs:
name: playwright-traces
path: test-results/
retention-days: 7
- name: Stop CI service stack
if: always()
run: docker compose --env-file .env.ci -f docker-compose.ci.yml down -v

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

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

View File

@@ -23,53 +23,6 @@ env:
REGISTRY_URL: ghcr.io/${{ github.repository_owner }}
jobs:
deploy-config:
name: Check Deploy Configuration
runs-on: ubuntu-latest
outputs:
staging_ready: ${{ steps.check.outputs.staging_ready }}
production_ready: ${{ steps.check.outputs.production_ready }}
steps:
- name: Check required deploy secrets
id: check
env:
TARGET_ENV: ${{ inputs.environment }}
STAGING_HOST: ${{ secrets.STAGING_HOST }}
STAGING_USER: ${{ secrets.STAGING_USER }}
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
STAGING_URL: ${{ secrets.STAGING_URL }}
STAGING_API_URL: ${{ secrets.STAGING_API_URL }}
PRODUCTION_HOST: ${{ secrets.PRODUCTION_HOST }}
PRODUCTION_USER: ${{ secrets.PRODUCTION_USER }}
PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
PRODUCTION_URL: ${{ secrets.PRODUCTION_URL }}
PRODUCTION_API_URL: ${{ secrets.PRODUCTION_API_URL }}
run: |
STAGING_READY=false
PRODUCTION_READY=false
if [ -n "$STAGING_HOST" ] && [ -n "$STAGING_USER" ] && [ -n "$STAGING_SSH_KEY" ] && [ -n "$STAGING_URL" ] && [ -n "$STAGING_API_URL" ]; then
STAGING_READY=true
fi
if [ -n "$PRODUCTION_HOST" ] && [ -n "$PRODUCTION_USER" ] && [ -n "$PRODUCTION_SSH_KEY" ] && [ -n "$PRODUCTION_URL" ] && [ -n "$PRODUCTION_API_URL" ]; then
PRODUCTION_READY=true
fi
echo "staging_ready=$STAGING_READY" >> "$GITHUB_OUTPUT"
echo "production_ready=$PRODUCTION_READY" >> "$GITHUB_OUTPUT"
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ "$TARGET_ENV" = "staging" ] && [ "$STAGING_READY" != "true" ]; then
echo "Missing required staging deploy secrets; configure STAGING_HOST, STAGING_USER, STAGING_SSH_KEY, STAGING_URL, and STAGING_API_URL."
exit 1
fi
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ "$TARGET_ENV" = "production" ] && [ "$PRODUCTION_READY" != "true" ]; then
echo "Missing required production deploy secrets; configure PRODUCTION_HOST, PRODUCTION_USER, PRODUCTION_SSH_KEY, PRODUCTION_URL, and PRODUCTION_API_URL."
exit 1
fi
build-api:
name: Build API Image
runs-on: ubuntu-latest
@@ -201,14 +154,11 @@ jobs:
deploy-staging:
name: Deploy to Staging
needs: [deploy-config, build-api, build-web, build-ai]
needs: [build-api, build-web, build-ai]
if: >-
needs.deploy-config.outputs.staging_ready == 'true' &&
(
github.ref == 'refs/heads/develop' ||
(github.event_name == 'push' && github.ref == 'refs/heads/master') ||
(github.event_name == 'workflow_dispatch' && inputs.environment == 'staging')
)
github.ref == 'refs/heads/develop' ||
(github.event_name == 'push' && github.ref == 'refs/heads/master') ||
(github.event_name == 'workflow_dispatch' && inputs.environment == 'staging')
runs-on: ubuntu-latest
environment: staging
@@ -271,17 +221,17 @@ jobs:
[ "\$PREV_WEB" != "none" ] && docker tag "\$PREV_WEB" goodgo-web:rollback 2>/dev/null || true
[ "\$PREV_AI" != "none" ] && docker tag "\$PREV_AI" goodgo-ai-services:rollback 2>/dev/null || true
# Pull new images
docker compose -f docker-compose.prod.yml pull api web ai-services
# Apply migrations with the newly pulled API image before switching app containers.
docker compose -f docker-compose.prod.yml run --rm --no-deps api \
npx prisma migrate deploy --schema /app/prisma/schema.prisma
# Rolling update — zero downtime
docker compose -f docker-compose.prod.yml up -d --no-deps --wait api
docker compose -f docker-compose.prod.yml up -d --no-deps --wait web
docker compose -f docker-compose.prod.yml up -d --no-deps --wait ai-services
# Run database migrations
docker compose -f docker-compose.prod.yml exec -T api npx prisma migrate deploy
# NOTE: docker image prune is NOT run here — it runs after smoke tests pass
DEPLOY_SCRIPT
@@ -444,11 +394,8 @@ jobs:
rollback-staging:
name: Rollback Staging
needs: [deploy-config, deploy-staging, smoke-test-staging]
if: >-
always() &&
needs.deploy-config.outputs.staging_ready == 'true' &&
(needs.deploy-staging.result == 'failure' || needs.smoke-test-staging.result == 'failure')
needs: [deploy-staging, smoke-test-staging]
if: failure()
runs-on: ubuntu-latest
environment: staging
@@ -515,11 +462,8 @@ jobs:
deploy-production:
name: Deploy to Production
needs: [deploy-config, build-api, build-web, build-ai]
if: >-
github.event_name == 'workflow_dispatch' &&
inputs.environment == 'production' &&
needs.deploy-config.outputs.production_ready == 'true'
needs: [build-api, build-web, build-ai]
if: inputs.environment == 'production'
runs-on: ubuntu-latest
environment: production
@@ -563,15 +507,13 @@ jobs:
docker compose -f docker-compose.prod.yml pull api web ai-services
# Apply migrations with the newly pulled API image before switching app containers.
docker compose -f docker-compose.prod.yml run --rm --no-deps api \
npx prisma migrate deploy --schema /app/prisma/schema.prisma
# Rolling update with health checks
docker compose -f docker-compose.prod.yml up -d --no-deps --wait api
docker compose -f docker-compose.prod.yml up -d --no-deps --wait web
docker compose -f docker-compose.prod.yml up -d --no-deps --wait ai-services
docker compose -f docker-compose.prod.yml exec -T api npx prisma migrate deploy
# NOTE: docker image prune is NOT run here — it runs after smoke tests pass
DEPLOY_SCRIPT
@@ -710,11 +652,8 @@ jobs:
rollback-production:
name: Rollback Production
needs: [deploy-config, deploy-production, smoke-test-production]
if: >-
always() &&
needs.deploy-config.outputs.production_ready == 'true' &&
(needs.deploy-production.result == 'failure' || needs.smoke-test-production.result == 'failure')
needs: [smoke-test-production]
if: failure()
runs-on: ubuntu-latest
environment: production

View File

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

View File

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

3
.gitignore vendored
View File

@@ -36,9 +36,6 @@ 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/

View File

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

View File

@@ -1,37 +1,5 @@
# 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

View File

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

View File

@@ -1,50 +0,0 @@
# Observability — Read-Model / Projector (RFC-003 Phase 0)
Grafana dashboards and wiring notes for the read-model observability stack
introduced in [GOO-192](/GOO/issues/GOO-192) under [GOO-94](/GOO/issues/GOO-94) §6 Phase 0.
## Metrics
All metrics live in the existing NestJS `metrics/` module
(`apps/api/src/modules/metrics/`) and are scraped via the standard `/metrics`
endpoint.
| Metric | Type | Labels | Purpose |
| --------------------------------------- | --------- | --------- | --------------------------------------------------------- |
| `read_model_projector_lag_seconds` | Gauge | `handler` | Seconds between latest source event and projector cursor. |
| `read_model_refresh_duration_seconds` | Histogram | `view` | Duration of read-model / materialised view refreshes. |
| `read_model_reconciliation_drift_total` | Counter | `model` | Count of drift discrepancies found during reconciliation. |
### Emit points
Inject `MetricsService` and call:
```ts
metrics.setProjectorLag(handler, lagSeconds);
metrics.recordReadModelRefresh(view, durationSeconds);
metrics.recordReconciliationDrift(model, count?);
```
## Dashboard
- File: `read-models-dashboard.json` (Grafana schema v38).
- Import into Grafana (`Dashboards → Import → Upload JSON`), pick the Prometheus
data source.
- Variables: `handler`, `view`, `model` — derived from Prometheus label values.
- Panels:
1. Projector lag by handler (time series + thresholded)
2. Max projector lag (stat, RAG 30s / 120s)
3. Refresh duration p50/p95 by view
4. Refresh throughput (refreshes/sec) by view
5. Reconciliation drift rate by model (15m rate)
6. Total drift events in last 24h (stat, RAG 1 / 10)
## Local verification
```bash
pnpm --filter @goodgo/api dev
curl -s http://localhost:3001/metrics | grep read_model_
```
All three metric families should appear with `# HELP` / `# TYPE` headers even
before any samples are recorded.

View File

@@ -1,77 +0,0 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"graphTooltip": 1,
"id": null,
"uid": "goodgo-read-models",
"title": "GoodGo · Read-Model Observability (RFC-003 Phase 0)",
"tags": ["goodgo", "rfc-003", "read-models", "observability"],
"timezone": "browser",
"schemaVersion": 38,
"version": 1,
"refresh": "30s",
"time": { "from": "now-6h", "to": "now" },
"templating": {
"list": [
{ "name": "datasource", "type": "datasource", "query": "prometheus", "current": { "text": "Prometheus", "value": "Prometheus" } },
{ "name": "handler", "type": "query", "datasource": "${datasource}", "query": "label_values(read_model_projector_lag_seconds, handler)", "includeAll": true, "multi": true, "refresh": 2 },
{ "name": "view", "type": "query", "datasource": "${datasource}", "query": "label_values(read_model_refresh_duration_seconds_bucket, view)", "includeAll": true, "multi": true, "refresh": 2 },
{ "name": "model", "type": "query", "datasource": "${datasource}", "query": "label_values(read_model_reconciliation_drift_total, model)", "includeAll": true, "multi": true, "refresh": 2 }
]
},
"panels": [
{
"id": 1, "type": "timeseries", "title": "Projector lag (seconds) — by handler",
"datasource": "${datasource}", "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"fieldConfig": { "defaults": { "unit": "s", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 30 }, { "color": "red", "value": 120 }] } } },
"targets": [{ "expr": "read_model_projector_lag_seconds{handler=~\"$handler\"}", "legendFormat": "{{handler}}", "refId": "A" }]
},
{
"id": 2, "type": "stat", "title": "Max projector lag (current)",
"datasource": "${datasource}", "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"fieldConfig": { "defaults": { "unit": "s", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 30 }, { "color": "red", "value": 120 }] } } },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } },
"targets": [{ "expr": "max(read_model_projector_lag_seconds{handler=~\"$handler\"})", "refId": "A" }]
},
{
"id": 3, "type": "timeseries", "title": "Refresh duration p50/p95 — by view",
"datasource": "${datasource}", "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"fieldConfig": { "defaults": { "unit": "s" } },
"targets": [
{ "expr": "histogram_quantile(0.95, sum by (view, le) (rate(read_model_refresh_duration_seconds_bucket{view=~\"$view\"}[5m])))", "legendFormat": "p95 · {{view}}", "refId": "A" },
{ "expr": "histogram_quantile(0.50, sum by (view, le) (rate(read_model_refresh_duration_seconds_bucket{view=~\"$view\"}[5m])))", "legendFormat": "p50 · {{view}}", "refId": "B" }
]
},
{
"id": 4, "type": "timeseries", "title": "Refresh throughput (refreshes/sec) — by view",
"datasource": "${datasource}", "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"fieldConfig": { "defaults": { "unit": "ops" } },
"targets": [{ "expr": "sum by (view) (rate(read_model_refresh_duration_seconds_count{view=~\"$view\"}[5m]))", "legendFormat": "{{view}}", "refId": "A" }]
},
{
"id": 5, "type": "timeseries", "title": "Reconciliation drift rate — by model",
"datasource": "${datasource}", "gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
"fieldConfig": { "defaults": { "unit": "ops" } },
"targets": [{ "expr": "sum by (model) (rate(read_model_reconciliation_drift_total{model=~\"$model\"}[15m]))", "legendFormat": "{{model}}", "refId": "A" }]
},
{
"id": 6, "type": "stat", "title": "Total drift events (last 24h)",
"datasource": "${datasource}", "gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
"fieldConfig": { "defaults": { "unit": "short", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 1 }, { "color": "red", "value": 10 }] } } },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } },
"targets": [{ "expr": "sum by (model) (increase(read_model_reconciliation_drift_total{model=~\"$model\"}[24h]))", "legendFormat": "{{model}}", "refId": "A" }]
}
]
}

View File

@@ -9,6 +9,7 @@
"start:prod": "node dist/main",
"lint": "eslint src/",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:integration": "vitest run --config vitest.integration.config.ts",
"typecheck": "tsc --noEmit"
},
@@ -16,11 +17,8 @@
"@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/ai-contract": "workspace:*",
"@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",
@@ -53,7 +51,6 @@
"handlebars": "^4.7.9",
"helmet": "^8.1.0",
"ioredis": "^5.4.0",
"jsonwebtoken": "^9.0.3",
"nodemailer": "^8.0.5",
"otplib": "^13.4.0",
"passport": "^0.7.0",
@@ -80,7 +77,6 @@
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.5.2",
"@types/nodemailer": "^8.0.0",
"@types/passport-google-oauth20": "^2.0.17",
@@ -90,6 +86,7 @@
"@types/qrcode": "^1.5.6",
"@types/sanitize-html": "^2.16.1",
"@types/supertest": "^7.2.0",
"@vitest/coverage-v8": "^4.1.3",
"prisma": "^7.7.0",
"supertest": "^7.2.2",
"typescript": "^6.0.2",

View File

@@ -20,11 +20,8 @@ import { McpIntegrationModule } from '@modules/mcp';
import { MessagingModule } from '@modules/messaging';
import { HttpMetricsInterceptor, MetricsModule } from '@modules/metrics';
import { NotificationsModule } from '@modules/notifications';
import { OsmSyncModule } from '@modules/osm-sync/osm-sync.module';
import { PaymentsModule } from '@modules/payments';
import { PoiModule } from '@modules/poi/poi.module';
import { ProjectsModule } from '@modules/projects';
import { QueuesModule } from '@modules/queues/queues.module';
import { ReportsModule } from '@modules/reports';
import { ReviewsModule } from '@modules/reviews';
import { SearchModule } from '@modules/search';
@@ -32,7 +29,6 @@ import { SharedModule } from '@modules/shared';
import { ThrottlerBehindProxyGuard } from '@modules/shared/infrastructure/guards/throttler-behind-proxy.guard';
import { CsrfMiddleware } from '@modules/shared/infrastructure/middleware/csrf.middleware';
import { SanitizeInputMiddleware } from '@modules/shared/infrastructure/middleware/sanitize-input.middleware';
import { getRedisConnection } from '@modules/shared/infrastructure/redis-connection.config';
import { SubscriptionsModule } from '@modules/subscriptions';
import { TransferModule } from '@modules/transfer';
import { AppController } from './app.controller';
@@ -41,11 +37,11 @@ import { AppController } from './app.controller';
imports: [
SentryModule.forRoot(),
BullModule.forRoot({
// RFC-004 Phase 3 — use the queue-specific Redis connection so ops can
// split cache traffic from queue traffic without a code change. Falls
// back to REDIS_HOST/PORT/PASSWORD when the queue-specific vars are
// unset. See shared/infrastructure/redis-connection.config.ts.
connection: getRedisConnection('queue'),
connection: {
host: process.env['REDIS_HOST'] ?? 'localhost',
port: Number(process.env['REDIS_PORT'] ?? 6379),
password: process.env['REDIS_PASSWORD'] ?? undefined,
},
}),
CqrsModule.forRoot(),
ScheduleModule.forRoot(),
@@ -60,14 +56,11 @@ import { AppController } from './app.controller';
FavoritesModule,
SearchModule,
NotificationsModule,
OsmSyncModule,
PaymentsModule,
PoiModule,
SubscriptionsModule,
AdminModule,
AnalyticsModule,
MetricsModule,
MetricsModule.withQueueMetrics(),
McpIntegrationModule,
MessagingModule,
ReportsModule,
@@ -75,9 +68,6 @@ 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
@@ -152,8 +142,6 @@ export class AppModule implements NestModule {
{ path: 'health/(.*)', method: RequestMethod.GET },
{ path: 'api/v1/web-vitals', method: RequestMethod.POST }, // sendBeacon cannot send CSRF headers
{ path: 'web-vitals', method: RequestMethod.POST }, // middleware exclude uses controller-relative path
{ path: 'api/v1/admin/queues', method: RequestMethod.ALL },
{ path: 'api/v1/admin/queues/(.*)', method: RequestMethod.ALL },
)
.forRoutes('*');
}

View File

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

View File

@@ -33,10 +33,10 @@ 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';
import { SystemSettingsAiConfigProvider } from './infrastructure/adapters/system-settings-ai-config.provider';
import { AdminModerationAuditController } from './presentation/controllers/admin-moderation-audit.controller';
import { AdminModerationController } from './presentation/controllers/admin-moderation.controller';
import { AdminController } from './presentation/controllers/admin.controller';

View File

@@ -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 { UserEntity } from '../../../../auth/domain/entities/user.entity';
import { USER_REPOSITORY, type IUserRepository } from '../../../../auth/domain/repositories/user.repository';
import { UserEntity } from '../../../../auth/domain/entities/user.entity';
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';

View File

@@ -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 { UserEntity } from '../../../../auth/domain/entities/user.entity';
import { USER_REPOSITORY, type IUserRepository } from '../../../../auth/domain/repositories/user.repository';
import { UserEntity } from '../../../../auth/domain/entities/user.entity';
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';

View File

@@ -1,4 +1,3 @@
import { Prisma } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import {
type DashboardStats,
@@ -81,12 +80,7 @@ export async function getRevenueStats(
return cached.data;
}
// 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 truncUnit = groupBy === 'day' ? 'day' : 'month';
const rows = await prisma.$queryRaw<RevenueRawRow[]>`
SELECT

View File

@@ -25,8 +25,6 @@ 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 {
@@ -39,6 +37,8 @@ import { ApproveListingDto } from '../dto/approve-listing.dto';
import { BulkModerateDto } from '../dto/bulk-moderate.dto';
import { RejectKycDto } from '../dto/reject-kyc.dto';
import { RejectListingDto } from '../dto/reject-listing.dto';
import { GetFlaggedListingsQuery } from '../../application/queries/get-flagged-listings/get-flagged-listings.query';
import type { FlaggedListingsResult } from '../../application/queries/get-flagged-listings/get-flagged-listings.handler';
@ApiTags('admin')
@ApiBearerAuth('JWT')

View File

@@ -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 { 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 { type AiSettingsDto } from '../../application/queries/get-ai-settings/get-ai-settings.handler';
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';

View File

@@ -1,7 +1,5 @@
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';
import { GenerateReportHandler } from './application/commands/generate-report/generate-report.handler';
@@ -9,21 +7,21 @@ import { TrackEventHandler } from './application/commands/track-event/track-even
import { UpdateMarketIndexHandler } from './application/commands/update-market-index/update-market-index.handler';
import { ListingCreatedModerationHandler } from './application/event-handlers/listing-created-moderation.handler';
import { BatchValuationHandler } from './application/queries/batch-valuation/batch-valuation.handler';
import { IndustrialValuationHandler } from './application/queries/industrial-valuation/industrial-valuation.handler';
import { GetDistrictStatsHandler } from './application/queries/get-district-stats/get-district-stats.handler';
import { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap.handler';
import { GetListingAiAdviceHandler } from './application/queries/get-listing-ai-advice/get-listing-ai-advice.handler';
import { GetListingVolumeWardHandler } from './application/queries/get-listing-volume-ward/get-listing-volume-ward.handler';
import { GetMarketHistoryHandler } from './application/queries/get-market-history/get-market-history.handler';
import { GetMarketReportHandler } from './application/queries/get-market-report/get-market-report.handler';
import { GetMarketHistoryHandler } from './application/queries/get-market-history/get-market-history.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 { GetTrendingAreasHandler } from './application/queries/get-trending-areas/get-trending-areas.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';
@@ -42,12 +40,6 @@ 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';
@@ -85,12 +77,7 @@ const EventHandlers = [
];
@Module({
imports: [
CqrsModule,
forwardRef(() => ListingsModule),
ProjectsModule,
forwardRef(() => AdminModule), // for AI_CONFIG_PROVIDER (used by AI advice handlers)
],
imports: [CqrsModule, forwardRef(() => ListingsModule), ProjectsModule],
controllers: [AnalyticsController, AvmController],
providers: [
// AI service client
@@ -110,25 +97,6 @@ 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,

View File

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

View File

@@ -1,6 +1,6 @@
import { InternalServerErrorException } from '@nestjs/common';
import { type PrismaService } from '@modules/shared';
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
import { type PrismaService } from '@modules/shared';
import { GetMarketSnapshotHandler } from '../queries/get-market-snapshot/get-market-snapshot.handler';
import { GetMarketSnapshotQuery } from '../queries/get-market-snapshot/get-market-snapshot.query';

View File

@@ -1,5 +1,5 @@
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
import { type INeighborhoodScoreService, type NeighborhoodScoreResult } from '../../domain/services/neighborhood-score.service';
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
import { GetNeighborhoodScoreHandler } from '../queries/get-neighborhood-score/get-neighborhood-score.handler';
import { GetNeighborhoodScoreQuery } from '../queries/get-neighborhood-score/get-neighborhood-score.query';

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { InternalServerErrorException } from '@nestjs/common';
import { DomainException, NotFoundException } from '@modules/shared';
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
import { DomainException, NotFoundException } from '@modules/shared';
import { ValuationEntity } from '../../domain/entities/valuation.entity';
import { type IValuationRepository } from '../../domain/repositories/valuation.repository';
import { ValuationExplanationHandler } from '../queries/valuation-explanation/valuation-explanation.handler';

View File

@@ -1,6 +1,6 @@
import { InternalServerErrorException } from '@nestjs/common';
import { DomainException } from '@modules/shared';
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
import { DomainException } from '@modules/shared';
import { ValuationEntity } from '../../domain/entities/valuation.entity';
import { type IValuationRepository } from '../../domain/repositories/valuation.repository';
import { ValuationHistoryHandler } from '../queries/valuation-history/valuation-history.handler';

View File

@@ -1,7 +1,7 @@
import { Inject } from '@nestjs/common';
import { EventsHandler, type IEventHandler, CommandBus } from '@nestjs/cqrs';
import { ModerateListingCommand } from '@modules/listings';
import { ListingCreatedEvent } from '@modules/listings/domain/events/listing-created.event';
import { ModerateListingCommand } from '@modules/listings/application/commands/moderate-listing/moderate-listing.command';
import { PrismaService, LoggerService } from '@modules/shared';
import {
AI_SERVICE_CLIENT,

View File

@@ -1,14 +1,5 @@
import { HttpStatus, Inject } from '@nestjs/common';
import { QueryBus, QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
// 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/domain/repositories/listing.repository';
import {
AI_CONFIG_PROVIDER,
DomainException,
@@ -16,8 +7,18 @@ import {
type IAIConfigProvider,
LoggerService,
} from '@modules/shared';
import {
LISTING_REPOSITORY,
type IListingRepository,
} from '@modules/listings/domain/repositories/listing.repository';
import { type ListingDetailData } from '../../../../listings/domain/repositories/listing-read.dto';
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 {
asInt,
asString,
@@ -27,12 +28,6 @@ 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). */

View File

@@ -1,7 +1,7 @@
import { InternalServerErrorException } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { type PropertyType, ListingStatus, Prisma } from '@prisma/client';
import { DomainException, CacheService, CachePrefix, CacheTTL, LoggerService, PrismaService } from '@modules/shared';
import { type PropertyType, ListingStatus, Prisma } from '@prisma/client';
import { GetMarketSnapshotQuery } from './get-market-snapshot.query';
export interface PriceChangePct {

View File

@@ -1,10 +1,5 @@
import { HttpStatus, Inject } from '@nestjs/common';
import { QueryBus, QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import {
PROJECT_REPOSITORY,
type IProjectRepository,
type ProjectDetailData,
} from '@modules/projects';
import {
AI_CONFIG_PROVIDER,
DomainException,
@@ -12,19 +7,26 @@ import {
type IAIConfigProvider,
LoggerService,
} from '@modules/shared';
import { type NeighborhoodScoreResult } from '../../../domain/services/neighborhood-score.service';
import { type AnthropicUsage,
import {
PROJECT_REPOSITORY,
type IProjectRepository,
type ProjectDetailData,
} from '@modules/projects';
import { type AnthropicUsage } from '../_shared/ai-json-client';
import {
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';

View File

@@ -1,4 +1,4 @@
import { InternalServerErrorException } from '@nestjs/common';
import { Inject, 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';

View File

@@ -4,16 +4,11 @@ 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> };
$queryRaw: ReturnType<typeof vi.fn>;
pOI: { count: ReturnType<typeof vi.fn> };
};
let mockLogger: { log: ReturnType<typeof vi.fn> };
@@ -23,7 +18,7 @@ describe('NeighborhoodScoreServiceImpl', () => {
findUnique: vi.fn(),
upsert: vi.fn(),
},
$queryRaw: vi.fn(),
pOI: { count: vi.fn() },
};
mockLogger = { log: vi.fn() };
@@ -65,45 +60,44 @@ describe('NeighborhoodScoreServiceImpl', () => {
});
describe('calculateAndSave', () => {
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),
);
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);
});
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 () => {
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),
);
// All categories have way more POIs than max
mockPrisma.pOI.count.mockResolvedValue(100);
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => {
return 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);
@@ -111,27 +105,25 @@ 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.$queryRaw.mockResolvedValue([]);
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
Promise.resolve(create),
);
mockPrisma.pOI.count.mockResolvedValue(0);
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => {
return 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.$queryRaw.mockResolvedValue(makePoiRows({ SCHOOL: 5 }));
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
Promise.resolve(create),
);
mockPrisma.pOI.count.mockResolvedValue(5);
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => {
return Promise.resolve(create);
});
await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
@@ -148,7 +140,7 @@ describe('HttpNeighborhoodScoreService', () => {
let prismaFallback: PrismaNeighborhoodScoreService;
let mockPrisma: {
neighborhoodScore: { findUnique: ReturnType<typeof vi.fn>; upsert: ReturnType<typeof vi.fn> };
$queryRaw: ReturnType<typeof vi.fn>;
pOI: { count: ReturnType<typeof vi.fn> };
};
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
let mockAiClient: { scoreNeighborhood: ReturnType<typeof vi.fn> };
@@ -156,7 +148,7 @@ describe('HttpNeighborhoodScoreService', () => {
beforeEach(() => {
mockPrisma = {
neighborhoodScore: { findUnique: vi.fn(), upsert: vi.fn() },
$queryRaw: vi.fn(),
pOI: { count: vi.fn() },
};
mockLogger = { log: vi.fn(), warn: vi.fn() };
mockAiClient = { scoreNeighborhood: vi.fn() };
@@ -173,7 +165,7 @@ describe('HttpNeighborhoodScoreService', () => {
});
it('persists AI service response when scoreNeighborhood succeeds', async () => {
mockPrisma.$queryRaw.mockResolvedValue(makePoiRows({ SCHOOL: 6 }));
mockPrisma.pOI.count.mockResolvedValue(6);
mockAiClient.scoreNeighborhood.mockResolvedValue({
district: 'Quận 1',
city: 'Hồ Chí Minh',
@@ -187,9 +179,7 @@ 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 }: { create: unknown }) =>
Promise.resolve(create),
);
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => Promise.resolve(create));
const result = await httpService.calculateAndSave('Quận 1', 'Hồ Chí Minh');
@@ -197,15 +187,12 @@ 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.$queryRaw.mockResolvedValue([]);
mockPrisma.pOI.count.mockResolvedValue(0);
mockAiClient.scoreNeighborhood.mockRejectedValue(new Error('AI service down'));
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
Promise.resolve(create),
);
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => Promise.resolve(create));
const result = await httpService.calculateAndSave('Quận 7', 'Hồ Chí Minh');

View File

@@ -24,20 +24,17 @@ describe('PrismaAVMService', () => {
mockPrisma.$queryRaw.mockResolvedValue([]);
await expect(service.estimateValue({ propertyId: 'non-existent' })).rejects.toThrow(
'Property not found: non-existent',
"Property with id 'non-existent' not found",
);
});
it('returns zero confidence when fewer than 3 comparables', async () => {
// 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() },
]);
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() },
]);
const result = await service.estimateValue({ propertyId: 'prop-1' });
@@ -47,15 +44,14 @@ describe('PrismaAVMService', () => {
});
it('calculates weighted valuation with sufficient comparables', async () => {
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() },
]);
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() },
]);
const result = await service.estimateValue({ propertyId: 'prop-1' });
@@ -67,8 +63,7 @@ describe('PrismaAVMService', () => {
});
it('uses coordinates directly when no propertyId', async () => {
// coords-only path: no property lookup, $queryRaw used for comparables directly
mockPrisma.$queryRaw.mockResolvedValue([
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() },
@@ -83,20 +78,18 @@ describe('PrismaAVMService', () => {
expect(result.confidence).toBeGreaterThan(0);
expect(Number(result.estimatedPrice)).toBeGreaterThan(0);
// coords-only path: $queryRaw is used for comparables; $queryRawUnsafe not called
expect(mockPrisma.$queryRawUnsafe).not.toHaveBeenCalled();
expect(mockPrisma.$queryRaw).not.toHaveBeenCalled();
});
});
describe('getComparables', () => {
it('returns comparables for a property', async () => {
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() },
]);
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() },
]);
const result = await service.getComparables('prop-1', 3000);

View File

@@ -146,35 +146,22 @@ 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 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
`;
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);
return rows.map((r) => ({
ward: r.ward,

View File

@@ -1,194 +1,67 @@
import {
AiRoutes,
type AVMPredictRequest,
type AVMPredictResponse,
type AVMv2PredictRequest,
type AVMv2PredictResponse,
type AVMv2Comparable,
type AVMv2FeatureImportance,
type IndustrialAVMRequest,
type IndustrialAVMResponse,
type IndustrialComparable,
type IndustrialFeatureImportance,
type ModerationRequest,
type ModerationResponse,
type ModerationFlag,
type NeighborhoodPOICounts,
type NeighborhoodScoreRequest,
type NeighborhoodScoreResponse,
} from '@goodgo/ai-contract';
import { Injectable } from '@nestjs/common';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires the runtime import so emitDecoratorMetadata can see the class (see project MEMORY.md)
import { LoggerService } from '@modules/shared';
import { DomainException } from '@modules/shared/domain/domain-exception';
import { ErrorCode } from '@modules/shared/domain/error-codes';
export interface AiPredictRequest {
area: number;
district: string;
city: string;
property_type: string;
bedrooms?: number;
bathrooms?: number;
floors?: number;
frontage?: number;
road_width?: number;
year_built?: number | null;
has_legal_paper?: boolean;
}
export interface AiPredictResponse {
estimated_price_vnd: number;
confidence: number;
price_per_m2: number;
price_range_low: number;
price_range_high: number;
}
/**
* AVM v2 request — extended feature set for residential ensemble.
* Matches `AVMv2PredictRequest` in libs/ai-services/app/models/avm_v2.py.
/*
* The DTO names below are aliases over `@goodgo/ai-contract` (auto-generated
* from the FastAPI OpenAPI schema at libs/ai-services). They are kept to
* preserve the existing public surface for callers under apps/api; the
* underlying shapes come from the contract package so a schema change in
* Python surfaces as a TypeScript compile error here.
*
* Do not hand-edit these aliases to diverge from the generated types. If
* the FastAPI schema changes, refresh the contract:
*
* pnpm --filter @goodgo/ai-contract export:openapi
* pnpm --filter @goodgo/ai-contract generate
*/
export interface AiPredictV2Request {
district: string;
city: string;
property_type: string;
area_m2: number;
distance_to_cbd_km?: number;
distance_to_metro_km?: number;
distance_to_school_km?: number;
distance_to_hospital_km?: number;
distance_to_park_km?: number;
distance_to_mall_km?: number;
flood_zone_risk?: number;
neighborhood_score?: number;
rooms?: number;
floor_level?: number;
total_floors?: number;
direction?: string;
floor_ratio?: number;
building_age_years?: number;
has_elevator?: boolean;
has_parking?: boolean;
has_pool?: boolean;
has_legal_paper?: boolean;
developer_reputation?: number;
avg_price_district_3m_vnd_m2?: number;
listing_density?: number;
absorption_rate?: number;
dom_avg?: number;
price_momentum_30d?: number;
yoy_change?: number;
renovation_score?: number;
view_quality?: number;
interior_quality?: number;
noise_level?: number;
natural_light?: number;
month?: number;
quarter?: number;
is_year_end?: boolean;
}
export interface AiPredictV2FeatureImportance {
feature: string;
importance: number;
}
// --- AVM v1 (legacy) ------------------------------------------------------
export type AiPredictRequest = AVMPredictRequest;
export type AiPredictResponse = AVMPredictResponse;
export interface AiPredictV2Comparable {
district: string;
property_type: string;
area_m2: number;
price_vnd: number;
price_per_m2_vnd: number;
similarity_score: number;
}
// --- AVM v2 (residential ensemble) ---------------------------------------
export type AiPredictV2Request = AVMv2PredictRequest;
export type AiPredictV2Response = AVMv2PredictResponse;
export type AiPredictV2FeatureImportance = AVMv2FeatureImportance;
export type AiPredictV2Comparable = AVMv2Comparable;
export interface AiPredictV2Response {
estimated_price_vnd: number;
confidence: number;
price_per_m2_vnd: number;
price_range_low_vnd: number;
price_range_high_vnd: number;
drivers?: AiPredictV2FeatureImportance[];
comparables?: AiPredictV2Comparable[];
model_version?: string;
ensemble_method?: string;
}
// --- Industrial AVM -------------------------------------------------------
export type AiIndustrialPredictRequest = IndustrialAVMRequest;
export type AiIndustrialPredictResponse = IndustrialAVMResponse;
export type AiIndustrialComparable = IndustrialComparable;
export type AiIndustrialFeatureImportance = IndustrialFeatureImportance;
export interface AiIndustrialPredictRequest {
province: string;
region: string;
park_occupancy_rate: number;
park_area_ha: number;
park_age_years: number;
distance_to_port_km: number;
distance_to_airport_km: number;
distance_to_highway_km: number;
property_type: string;
area_m2: number;
ceiling_height_m?: number;
floor_load_ton_m2?: number;
power_capacity_kva?: number;
building_coverage?: number;
loading_docks?: number;
zoning?: string;
industry_demand_index?: number;
fdi_province_musd?: number;
labor_cost_province_vnd?: number;
logistics_connectivity_score?: number;
}
// --- Moderation -----------------------------------------------------------
export type AiModerationRequest = ModerationRequest;
export type AiModerationResponse = ModerationResponse;
export type AiModerationFlag = ModerationFlag;
export interface AiIndustrialComparable {
park_name: string;
province: string;
property_type: string;
area_m2: number;
rent_usd_m2: number;
similarity_score: number;
}
export interface AiIndustrialFeatureImportance {
feature: string;
importance: number;
}
export interface AiIndustrialPredictResponse {
estimated_rent_usd_m2: number;
confidence: number;
rent_range_low_usd_m2: number;
rent_range_high_usd_m2: number;
annual_rent_usd_m2: number;
total_monthly_rent_usd: number;
comparables: AiIndustrialComparable[];
drivers: AiIndustrialFeatureImportance[];
model_version: string;
}
export interface AiModerationRequest {
text: string;
context?: string;
}
export interface AiModerationFlag {
category: string;
severity: string;
matched_text: string;
reason: string;
}
export interface AiModerationResponse {
is_flagged: boolean;
score: number;
flags: AiModerationFlag[];
cleaned_text: string | null;
}
export interface AiNeighborhoodPOICounts {
education: number;
healthcare: number;
transport: number;
shopping: number;
greenery: number;
safety: number;
}
export interface AiNeighborhoodScoreRequest {
district: string;
city: string;
poi_counts: AiNeighborhoodPOICounts;
}
export interface AiNeighborhoodScoreResponse {
district: string;
city: string;
education_score: number;
healthcare_score: number;
transport_score: number;
shopping_score: number;
greenery_score: number;
safety_score: number;
total_score: number;
poi_counts: Record<string, number>;
algorithm_version: string;
}
// --- Neighborhood scoring -------------------------------------------------
export type AiNeighborhoodPOICounts = NeighborhoodPOICounts;
export type AiNeighborhoodScoreRequest = NeighborhoodScoreRequest;
export type AiNeighborhoodScoreResponse = NeighborhoodScoreResponse;
export const AI_SERVICE_CLIENT = Symbol('AI_SERVICE_CLIENT');
@@ -214,30 +87,30 @@ export class AiServiceClient implements IAiServiceClient {
}
async predict(req: AiPredictRequest): Promise<AiPredictResponse> {
return this.post<AiPredictResponse>('/avm/predict', req);
return this.post<AiPredictResponse>(AiRoutes.avmPredict, req);
}
async predictV2(req: AiPredictV2Request): Promise<AiPredictV2Response> {
return this.post<AiPredictV2Response>('/avm/v2/predict', req);
return this.post<AiPredictV2Response>(AiRoutes.avmV2Predict, req);
}
async predictIndustrial(req: AiIndustrialPredictRequest): Promise<AiIndustrialPredictResponse> {
return this.post<AiIndustrialPredictResponse>('/avm/industrial/predict', req);
return this.post<AiIndustrialPredictResponse>(AiRoutes.avmIndustrialPredict, req);
}
async moderate(req: AiModerationRequest): Promise<AiModerationResponse> {
return this.post<AiModerationResponse>('/moderation/check', req);
return this.post<AiModerationResponse>(AiRoutes.moderationCheck, req);
}
async scoreNeighborhood(
req: AiNeighborhoodScoreRequest,
): Promise<AiNeighborhoodScoreResponse> {
return this.post<AiNeighborhoodScoreResponse>('/neighborhood/score', req);
return this.post<AiNeighborhoodScoreResponse>(AiRoutes.neighborhoodScore, req);
}
async isAvailable(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/health`, {
const response = await fetch(`${this.baseUrl}${AiRoutes.health}`, {
method: 'GET',
signal: AbortSignal.timeout(2000),
});
@@ -265,7 +138,10 @@ export class AiServiceClient implements IAiServiceClient {
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`AI service ${path} returned ${response.status}: ${text}`);
throw new DomainException(
ErrorCode.AI_PROVIDER_ERROR,
`AI service ${path} returned ${response.status}: ${text}`,
);
}
return response.json() as Promise<T>;

View File

@@ -1,6 +1,8 @@
import { Injectable } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PrismaService, LoggerService } from '@modules/shared';
import { DomainException } from '@modules/shared/domain/domain-exception';
import { ErrorCode } from '@modules/shared/domain/error-codes';
@Injectable()
export class AvmRetrainCronService {
@@ -101,7 +103,7 @@ export class AvmRetrainCronService {
CASE WHEN p.amenities::text ILIKE '%parking%' THEN 1.0 ELSE 0.0 END AS has_parking,
CASE WHEN p.amenities::text ILIKE '%pool%' THEN 1.0 ELSE 0.0 END AS has_pool,
CASE
WHEN p."legalStatus" IN ('so_do', 'so_hong', 'SO_DO', 'SO_HONG') THEN 1.0
WHEN p."legalStatus"::text IN ('SO_DO', 'SO_HONG') THEN 1.0
ELSE 0.0
END AS has_legal_paper,
0.5 AS developer_reputation,
@@ -206,7 +208,10 @@ export class AvmRetrainCronService {
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`Training data upload failed (${response.status}): ${text}`);
throw new DomainException(
ErrorCode.AI_PROVIDER_ERROR,
`Training data upload failed (${response.status}): ${text}`,
);
}
this.logger.log(
@@ -235,7 +240,10 @@ export class AvmRetrainCronService {
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`Retrain request failed (${response.status}): ${text}`);
throw new DomainException(
ErrorCode.AI_PROVIDER_ERROR,
`Retrain request failed (${response.status}): ${text}`,
);
}
return response.json() as Promise<RetrainResult>;
@@ -280,7 +288,7 @@ interface RawTrainingRow {
price_vnd: number;
}
type TrainingRow = RawTrainingRow;
interface TrainingRow extends RawTrainingRow {}
interface RetrainResult {
model_version: string;

View File

@@ -14,7 +14,6 @@ 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> = {
@@ -23,6 +22,7 @@ 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;

View File

@@ -3,10 +3,3 @@ 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';

View File

@@ -143,26 +143,18 @@ async function countPOIs(
district: string,
city: string,
): Promise<AiNeighborhoodPOICounts> {
// 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;
});
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;
}),
);
return Object.fromEntries(entries) as unknown as AiNeighborhoodPOICounts;
}

View File

@@ -1,6 +1,8 @@
import { Injectable } from '@nestjs/common';
import { type PropertyType } from '@prisma/client';
import { PrismaService } from '@modules/shared';
import { DomainException, NotFoundException } from '@modules/shared/domain/domain-exception';
import { ErrorCode } from '@modules/shared/domain/error-codes';
import {
type IAVMService,
type AVMParams,
@@ -113,7 +115,10 @@ export class PrismaAVMService implements IAVMService {
};
}
throw new Error('Either propertyId or (latitude, longitude, areaM2) must be provided');
throw new DomainException(
ErrorCode.VALIDATION_FAILED,
'Either propertyId or (latitude, longitude, areaM2) must be provided',
);
}
private async getPropertyLocation(propertyId: string): Promise<PropertyLocation> {
@@ -127,7 +132,7 @@ export class PrismaAVMService implements IAVMService {
LIMIT 1
`;
const row = rows[0];
if (!row) throw new Error(`Property not found: ${propertyId}`);
if (!row) throw new NotFoundException('Property', propertyId);
return row;
}
@@ -136,35 +141,23 @@ export class PrismaAVMService implements IAVMService {
propertyType: PropertyType | undefined,
radiusMeters: number,
): Promise<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[]>`
const typeFilter = propertyType ? `AND p."propertyType" = '${propertyType}'` : '';
return this.prisma.$queryRawUnsafe<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,
ST_Distance(p.location::geography, ST_SetSRID(ST_MakePoint($1, $2), 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 ST_DWithin(p.location::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, $3)
${typeFilter}
ORDER BY distance_meters ASC LIMIT 20
`;
`,
lng, lat, radiusMeters,
);
}
}

View File

@@ -1,199 +0,0 @@
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;
}
}
}

View File

@@ -1,5 +1,6 @@
import { type ExecutionContext, type CallHandler } from '@nestjs/common';
import { of, lastValueFrom } from 'rxjs';
import { of } from 'rxjs';
import { lastValueFrom } from 'rxjs';
import { cacheMetaStorage } from '@modules/shared';
import { CacheMetaInterceptor, type WithCacheMeta } from '../interceptors/cache-meta.interceptor';

View File

@@ -13,37 +13,38 @@ 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 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 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 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';
@@ -57,18 +58,17 @@ import { BatchValuationDto } from '../dto/batch-valuation.dto';
import { GetDistrictStatsDto } from '../dto/get-district-stats.dto';
import { GetHeatmapDto } from '../dto/get-heatmap.dto';
import { GetListingVolumeWardDto } from '../dto/get-listing-volume-ward.dto';
import { GetMarketHistoryDto } from '../dto/get-market-history.dto';
import { GetMarketReportDto } from '../dto/get-market-report.dto';
import { GetMarketHistoryDto } from '../dto/get-market-history.dto';
import { GetMarketSnapshotDto } from '../dto/get-market-snapshot.dto';
import { GetNearbyPOIsDto } from '../dto/get-nearby-pois.dto';
import { GetPriceMoversDto } from '../dto/get-price-movers.dto';
import { GetPriceTrendDto } from '../dto/get-price-trend.dto';
import { GetTrendingAreasDto } from '../dto/get-trending-areas.dto';
import { GetNearbyPOIsDto } from '../dto/get-nearby-pois.dto';
import { GetPriceTrendDto } from '../dto/get-price-trend.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)

View File

@@ -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 { ValuationHistoryDto } from '../dto/valuation-history.dto';
import { CacheMetaInterceptor } from '../interceptors/cache-meta.interceptor';
import { ValuationHistoryDto } from '../dto/valuation-history.dto';
@ApiTags('avm')
@UseInterceptors(CacheMetaInterceptor)

View File

@@ -43,5 +43,5 @@ export class GetPriceMoversDto {
})
@IsOptional()
@IsIn(['district'])
level = 'district' as const;
level: 'district' = 'district';
}

View File

@@ -37,5 +37,5 @@ export class GetTrendingAreasDto {
})
@IsOptional()
@IsIn(['district'])
level = 'district' as const;
level: 'district' = 'district';
}

View File

@@ -1,16 +1,7 @@
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;
@@ -26,13 +17,7 @@ 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',
@@ -44,25 +29,12 @@ 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 and returns a stream', async () => {
it('exports all user data including relations', async () => {
mockPrisma.user.findUnique.mockResolvedValue(sampleUser);
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1', userId: 'user-1' });
mockPrisma.listing.findMany.mockResolvedValue([{ id: 'listing-1' }]);
@@ -74,77 +46,43 @@ 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(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);
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);
});
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 and cap metadata in the payload', async () => {
it('includes exportedAt timestamp', async () => {
mockPrisma.user.findUnique.mockResolvedValue(sampleUser);
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([]);
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(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();
expect(result.exportedAt).toBeDefined();
expect(result.exportedAt >= before).toBe(true);
expect(result.exportedAt <= after).toBe(true);
});
});

View File

@@ -5,8 +5,6 @@ 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',
@@ -17,30 +15,22 @@ describe('LoginUserHandler', () => {
beforeEach(() => {
mockTokenService = { generateTokenPair: vi.fn().mockResolvedValue(tokenPair) };
mockChallengeRepo = { create: vi.fn().mockResolvedValue({}) };
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,
);
handler = new LoginUserHandler(mockTokenService as any, mockChallengeRepo as any);
});
it('generates token pair with mfa=none for non-required role when MFA not required', async () => {
it('generates token pair with correct payload 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, mfaGraceRemainingDays: undefined });
expect(result).toEqual({ requiresMfa: false, tokens: tokenPair });
expect(mockTokenService.generateTokenPair).toHaveBeenCalledWith({
sub: 'user-1',
phone: '0912345678',
role: 'BUYER',
mfa: 'none',
});
});
it('creates MFA challenge when MFA is required (user already enrolled)', async () => {
it('creates MFA challenge when MFA is required', async () => {
const command = new LoginUserCommand('user-1', '0912345678', 'BUYER', true);
const result = await handler.execute(command);
@@ -59,7 +49,7 @@ describe('LoginUserHandler', () => {
);
});
it('AGENT role does not require MFA — issues mfa=none claim', async () => {
it('passes AGENT role correctly', async () => {
const command = new LoginUserCommand('user-2', '0987654321', 'AGENT');
await handler.execute(command);
@@ -67,51 +57,17 @@ describe('LoginUserHandler', () => {
sub: 'user-2',
phone: '0987654321',
role: 'AGENT',
mfa: 'none',
});
});
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);
it('passes ADMIN role correctly', async () => {
const command = new LoginUserCommand('admin-1', '0901234567', 'ADMIN');
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',
});
});
});

View File

@@ -1,14 +1,8 @@
import { Readable } from 'node:stream';
import { HttpException, InternalServerErrorException, PayloadTooLargeException } from '@nestjs/common';
import { InternalServerErrorException } 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;
@@ -28,34 +22,16 @@ 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<ExportUserDataResult> {
async execute(command: ExportUserDataCommand): Promise<UserDataExport> {
try {
const user = await this.prisma.user.findUnique({
where: { id: command.userId },
@@ -67,29 +43,27 @@ 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 }, 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.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 } }),
]);
const payload: UserDataExport = {
this.logger.log(`User data exported for ${command.userId}`, 'ExportUserDataHandler');
return {
user,
agent,
listings,
@@ -100,34 +74,9 @@ 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 || error instanceof HttpException) throw error;
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to export user data: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,

View File

@@ -4,7 +4,5 @@ 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,
) {}
}

View File

@@ -1,18 +1,12 @@
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 {
USER_REPOSITORY,
type IUserRepository,
} from '../../../domain/repositories/user.repository';
import { TokenService, type MfaClaim, type TokenPair } from '../../../infrastructure/services/token.service';
import { TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
import { LoginUserCommand } from './login-user.command';
const MFA_CHALLENGE_TTL_MINUTES = 5;
@@ -21,7 +15,6 @@ export interface LoginResult {
requiresMfa: boolean;
challengeId?: string;
tokens?: TokenPair;
mfaGraceRemainingDays?: number;
}
@CommandHandler(LoginUserCommand)
@@ -30,14 +23,12 @@ 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 (user already enrolled), create a challenge
// If MFA is required, create a challenge instead of tokens
if (command.isMfaRequired) {
const challengeId = createId();
const expiresAt = new Date();
@@ -59,32 +50,16 @@ export class LoginUserHandler implements ICommandHandler<LoginUserCommand> {
};
}
// 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;
}
// No MFA — issue tokens directly
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;
@@ -96,33 +71,5 @@ 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 };
}
}

View File

@@ -3,6 +3,7 @@ import { CqrsModule } from '@nestjs/cqrs';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { MulterModule } from '@nestjs/platform-express';
import { DomainException, ErrorCode } from '@modules/shared';
import {
MEDIA_STORAGE_SERVICE,
MinioMediaStorageService,
@@ -14,12 +15,13 @@ 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 { RequestPhoneOtpHandler } from './application/commands/request-phone-otp/request-phone-otp.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';
@@ -29,6 +31,7 @@ import { VerifyKycHandler } from './application/commands/verify-kyc/verify-kyc.h
import { VerifyMfaChallengeHandler } from './application/commands/verify-mfa-challenge/verify-mfa-challenge.handler';
import { VerifyMfaSetupHandler } from './application/commands/verify-mfa-setup/verify-mfa-setup.handler';
import { VerifyPhoneChangeHandler } from './application/commands/verify-phone-change/verify-phone-change.handler';
import { VerifyPhoneOtpHandler } from './application/commands/verify-phone-otp/verify-phone-otp.handler';
import { GetAgentByUserIdHandler } from './application/queries/get-agent-by-user-id/get-agent-by-user-id.handler';
import { GetMfaStatusHandler } from './application/queries/get-mfa-status/get-mfa-status.handler';
import { GetProfileHandler } from './application/queries/get-profile/get-profile.handler';
@@ -54,6 +57,8 @@ const CommandHandlers = [
RegisterUserHandler,
LoginUserHandler,
RefreshTokenHandler,
RequestPhoneOtpHandler,
VerifyPhoneOtpHandler,
VerifyKycHandler,
SubmitKycHandler,
GenerateKycUploadUrlsHandler,
@@ -89,7 +94,7 @@ const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler, GetMfaStatusH
useFactory: () => {
const secret = process.env['JWT_SECRET'];
if (!secret) {
throw new Error('JWT_SECRET environment variable is required');
throw new DomainException(ErrorCode.AUTH_CONFIG_MISSING, 'JWT_SECRET environment variable is required');
}
return {
secret,

View File

@@ -22,8 +22,6 @@ export interface UserProps {
totpEnabled: boolean;
totpBackupCodes: string[];
totpEnabledAt: Date | null;
mfaGraceStartedAt: Date | null;
mfaLastVerifiedAt: Date | null;
}
export class UserEntity extends AggregateRoot<string> {
@@ -41,8 +39,6 @@ export class UserEntity extends AggregateRoot<string> {
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);
@@ -60,8 +56,6 @@ export class UserEntity extends AggregateRoot<string> {
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; }
@@ -78,8 +72,6 @@ export class UserEntity extends AggregateRoot<string> {
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,
@@ -104,8 +96,6 @@ export class UserEntity extends AggregateRoot<string> {
totpEnabled: false,
totpBackupCodes: [],
totpEnabledAt: null,
mfaGraceStartedAt: null,
mfaLastVerifiedAt: null,
});
user.addDomainEvent(new UserRegisteredEvent(id, phone.value, role));
@@ -143,8 +133,6 @@ export class UserEntity extends AggregateRoot<string> {
totpEnabled: false,
totpBackupCodes: [],
totpEnabledAt: null,
mfaGraceStartedAt: null,
mfaLastVerifiedAt: null,
});
user.addDomainEvent(new UserRegisteredEvent(id, phone.value, role));

View File

@@ -1,12 +0,0 @@
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,
) {}
}

View File

@@ -1,28 +0,0 @@
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;

View File

@@ -12,6 +12,4 @@ 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>;
}

View File

@@ -17,4 +17,3 @@ 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';

View File

@@ -1,27 +0,0 @@
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); });
});

View File

@@ -23,10 +23,22 @@ 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 {},
}));
vi.mock('@modules/shared', () => {
const { HttpException } = require('@nestjs/common');
class DomainException extends HttpException {
constructor(public readonly errorCode: string, message: string, statusCode = 500) {
super(message, statusCode);
}
}
return {
PrismaService: class {},
RedisService: class {},
DomainException,
ErrorCode: {
AUTH_CONFIG_MISSING: 'AUTH_CONFIG_MISSING',
},
};
});
type PrismaStub = { user: { findUnique: ReturnType<typeof vi.fn> } };
type RedisStub = {

View File

@@ -160,8 +160,6 @@ describe('LocalStrategy', () => {
phone: '+84912345678',
role: 'BUYER',
isMfaRequired: false,
totpEnabled: false,
mfaGraceStartedAt: undefined,
});
});

View File

@@ -1,61 +1,158 @@
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(() => {
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);
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 expiresAt = mockRefreshTokenRepo.create.mock.calls[0][0].expiresAt as Date;
const daysDiff = Math.round((expiresAt.getTime() - Date.now()) / 86400000);
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));
expect(daysDiff).toBeGreaterThanOrEqual(29);
expect(daysDiff).toBeLessThanOrEqual(31);
});
});
describe('rotateRefreshToken', () => {
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(); });
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();
});
});
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('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('verifyAccessToken', () => {
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(); });
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();
});
});
});

View File

@@ -123,14 +123,6 @@ 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;
@@ -153,8 +145,6 @@ export class PrismaUserRepository implements IUserRepository {
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);

View File

@@ -1,8 +1,8 @@
import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable, HttpStatus } from '@nestjs/common';
import { EventBus } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
import { type OAuthProvider, type Prisma } from '@prisma/client';
import { PrismaService, LoggerService } from '@modules/shared';
import { PrismaService, LoggerService, DomainException, ErrorCode } from '@modules/shared';
import { UserEntity } from '../../domain/entities/user.entity';
import { UserRegisteredEvent } from '../../domain/events/user-registered.event';
import { USER_REPOSITORY, type IUserRepository } from '../../domain/repositories/user.repository';
@@ -62,7 +62,7 @@ export class OAuthService {
});
if (!existingOAuth.user.isActive) {
throw new Error('Tài khoản đã bị vô hiệu hóa');
throw new DomainException(ErrorCode.AUTH_ACCOUNT_DISABLED, 'Tài khoản đã bị vô hiệu hóa', HttpStatus.FORBIDDEN);
}
this.logger.log(`OAuth login: existing account for ${profile.provider}/${profile.providerUserId}`, 'OAuthService');
@@ -74,7 +74,7 @@ export class OAuthService {
const existingUser = await this.userRepo.findByEmail(profile.email);
if (existingUser) {
if (!existingUser.isActive) {
throw new Error('Tài khoản đã bị vô hiệu hóa');
throw new DomainException(ErrorCode.AUTH_ACCOUNT_DISABLED, 'Tài khoản đã bị vô hiệu hóa', HttpStatus.FORBIDDEN);
}
await this.createOAuthAccount(existingUser.id, profile);
this.logger.log(`OAuth link: linked ${profile.provider} to existing user by email`, 'OAuthService');
@@ -93,7 +93,7 @@ export class OAuthService {
const existingUser = await this.userRepo.findByPhone(phoneVo.unwrap().value);
if (existingUser) {
if (!existingUser.isActive) {
throw new Error('Tài khoản đã bị vô hiệu hóa');
throw new DomainException(ErrorCode.AUTH_ACCOUNT_DISABLED, 'Tài khoản đã bị vô hiệu hóa', HttpStatus.FORBIDDEN);
}
await this.createOAuthAccount(existingUser.id, profile);
this.logger.log(`OAuth link: linked ${profile.provider} to existing user by phone`, 'OAuthService');
@@ -126,8 +126,6 @@ export class OAuthService {
totpEnabled: false,
totpBackupCodes: [],
totpEnabledAt: null,
mfaGraceStartedAt: null,
mfaLastVerifiedAt: null,
});
await this.userRepo.save(user);

View File

@@ -5,25 +5,11 @@ 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 {
@@ -40,60 +26,102 @@ 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) { await this.refreshTokenRepo.revokeByFamily(family); return null; }
if (existing.revokedAt || existing.expiresAt < new Date()) { await this.refreshTokenRepo.revokeByFamily(existing.family); return null; }
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
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 {
return verifyWithRotation<JwtPayload>(token, this.primarySecret, this.previousSecret);
try {
return this.jwtService.verify<JwtPayload>(token);
} catch {
return null;
}
}
private hashToken(token: string): string { return createHash('sha256').update(token).digest('hex'); }
private hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
}

View File

@@ -2,9 +2,9 @@ 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';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
import { PrismaService, RedisService, DomainException, ErrorCode } 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;
@@ -12,33 +12,88 @@ function extractJwtFromCookieOrHeader(req: Request): string | null {
return ExtractJwt.fromAuthHeaderAsBearerToken()(req);
}
interface CachedUserStatus { isActive: boolean; deletedAt: string | null; }
/** Cached user status — JSON encoded in Redis. */
interface CachedUserStatus {
isActive: boolean;
deletedAt: string | null;
}
/**
* Redis key prefix for user status cache. Versioned so that a schema
* change can invalidate all stale entries by bumping the version.
*/
export const USER_STATUS_CACHE_PREFIX = 'auth:user_status:v1';
/** TTL for cached user status (seconds). */
export const USER_STATUS_CACHE_TTL_SECONDS = 60;
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly prisma: PrismaService, private readonly redis: RedisService) {
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');
const previousSecret = process.env['JWT_SECRET_PREVIOUS'] || undefined;
super({ jwtFromRequest: extractJwtFromCookieOrHeader, ignoreExpiration: false, secretOrKeyProvider: makeSecretOrKeyProvider(jwtSecret, previousSecret), audience: 'goodgo-api', issuer: 'goodgo-platform' });
if (!jwtSecret) {
throw new DomainException(ErrorCode.AUTH_CONFIG_MISSING, 'JWT_SECRET environment variable is required');
}
super({
jwtFromRequest: extractJwtFromCookieOrHeader,
ignoreExpiration: false,
secretOrKey: jwtSecret,
audience: 'goodgo-api',
issuer: 'goodgo-platform',
});
}
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');
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 };
}
/**
* Loads user status from Redis cache if present, otherwise from DB and
* populates the cache with a 60 s TTL. Redis failures are non-fatal:
* we fall back to DB so a Redis outage cannot lock out all users.
*
* Returns null only when the user does not exist in the DB.
*/
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 (this.redis.isAvailable()) {
try {
const cached = await this.redis.get(cacheKey);
if (cached !== null) {
return JSON.parse(cached) as CachedUserStatus;
}
} catch {
// Swallow: degrade to DB on Redis read error.
}
}
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 */ } }
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: cache population is best-effort.
}
}
return status;
}
}

View File

@@ -9,8 +9,6 @@ export interface LocalStrategyResult {
phone: string;
role: string;
isMfaRequired: boolean;
totpEnabled: boolean;
mfaGraceStartedAt: Date | null;
}
@Injectable()
@@ -58,8 +56,6 @@ 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;

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { LoggerService } from '@modules/shared';
import { HttpStatus, Injectable } from '@nestjs/common';
import { DomainException, ErrorCode, LoggerService } from '@modules/shared';
import { OAuthService, type OAuthUserProfile } from '../services/oauth.service';
import { type TokenPair } from '../services/token.service';
@@ -126,11 +126,19 @@ export class ZaloOAuthStrategy {
if (data.error) {
this.logger.error(`Zalo token exchange failed: ${data.error_description ?? data.error}`, undefined, 'ZaloOAuthStrategy');
throw new Error(`Zalo OAuth error: ${data.error_description ?? 'Token exchange failed'}`);
throw new DomainException(
ErrorCode.AUTH_OAUTH_PROVIDER_ERROR,
`Zalo OAuth error: ${data.error_description ?? 'Token exchange failed'}`,
HttpStatus.BAD_GATEWAY,
);
}
if (!data.access_token) {
throw new Error('Zalo OAuth error: No access token in response');
throw new DomainException(
ErrorCode.AUTH_OAUTH_PROVIDER_ERROR,
'Zalo OAuth error: No access token in response',
HttpStatus.BAD_GATEWAY,
);
}
return data;
@@ -150,11 +158,19 @@ export class ZaloOAuthStrategy {
if (data.error) {
this.logger.error(`Zalo user info fetch failed: ${data.message ?? data.error}`, undefined, 'ZaloOAuthStrategy');
throw new Error(`Zalo OAuth error: ${data.message ?? 'Failed to fetch user info'}`);
throw new DomainException(
ErrorCode.AUTH_OAUTH_PROVIDER_ERROR,
`Zalo OAuth error: ${data.message ?? 'Failed to fetch user info'}`,
HttpStatus.BAD_GATEWAY,
);
}
if (!data.id) {
throw new Error('Zalo OAuth error: No user ID in response');
throw new DomainException(
ErrorCode.AUTH_OAUTH_PROVIDER_ERROR,
'Zalo OAuth error: No user ID in response',
HttpStatus.BAD_GATEWAY,
);
}
return data;

View File

@@ -1,21 +0,0 @@
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);
};
}

View File

@@ -62,23 +62,7 @@ import { RolesGuard } from '../guards/roles.guard';
const IS_PRODUCTION = process.env['NODE_ENV'] === 'production';
const IS_TEST = process.env['NODE_ENV'] === 'test';
/**
* 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 AUTH_RATE_LIMIT = IS_TEST ? 10_000 : 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
@@ -125,7 +109,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 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'ip' })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 3, windowSeconds: 60, keyStrategy: 'ip' })
@UseGuards(EndpointRateLimitGuard)
@Post('register')
@ApiOperation({ summary: 'Register a new user' })
@@ -148,7 +132,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 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'ip' })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'ip' })
@UseGuards(EndpointRateLimitGuard, LocalAuthGuard)
@Post('login')
@ApiOperation({ summary: 'Login with phone and password' })
@@ -214,7 +198,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 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'ip' })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 3, windowSeconds: 60, keyStrategy: 'ip' })
@UseGuards(EndpointRateLimitGuard)
@Post('forgot-password')
@ApiOperation({
@@ -231,7 +215,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 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'ip' })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'ip' })
@UseGuards(EndpointRateLimitGuard)
@Post('reset-password')
@ApiOperation({ summary: 'Reset password using OTP code' })
@@ -247,7 +231,7 @@ 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' })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'ip' })
@UseGuards(EndpointRateLimitGuard)
@Post('exchange-token')
@ApiOperation({ summary: 'Exchange OAuth token pair for httpOnly cookies' })
@@ -302,7 +286,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 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'user' })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'user' })
@UseGuards(JwtAuthGuard, EndpointRateLimitGuard)
@Post('profile/verify-phone')
@ApiBearerAuth('JWT')
@@ -323,7 +307,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 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'user' })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'user' })
@UseGuards(JwtAuthGuard, EndpointRateLimitGuard)
@Post('profile/verify-email')
@ApiBearerAuth('JWT')

View File

@@ -5,16 +5,13 @@ import {
Get,
Param,
Post,
Res,
StreamableFile,
UseGuards,
} from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiProduces } from '@nestjs/swagger';
import { Response } from 'express';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
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 ExportUserDataResult } from '../../application/commands/export-user-data/export-user-data.handler';
import { type UserDataExport } 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';
@@ -61,33 +58,13 @@ export class UserDataController {
@Get('me/export')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT')
@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' })
@ApiOperation({ summary: 'Export user data (GDPR Article 20)' })
@ApiResponse({ status: 200, description: 'User data exported as JSON' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({
status: 413,
description: 'Export exceeds size cap — contact support for chunked export',
})
async exportData(
@CurrentUser() user: JwtPayload,
@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);
): Promise<UserDataExport> {
return this.commandBus.execute(new ExportUserDataCommand(user.sub));
}
@Delete(':id/force')

View File

@@ -1,144 +0,0 @@
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();
});
});

View File

@@ -1,117 +0,0 @@
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');
});
});

View File

@@ -1,138 +0,0 @@
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();
});
});

View File

@@ -1,120 +0,0 @@
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');
});
});

View File

@@ -1,180 +0,0 @@
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);
});
});

View File

@@ -1,7 +0,0 @@
export class ApproveDocumentCommand {
constructor(
public readonly documentId: string,
public readonly adminId: string,
public readonly notes?: string,
) {}
}

View File

@@ -1,59 +0,0 @@
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ý');
}
}
}

View File

@@ -1,7 +0,0 @@
export class RejectDocumentCommand {
constructor(
public readonly documentId: string,
public readonly adminId: string,
public readonly reason: string,
) {}
}

View File

@@ -1,52 +0,0 @@
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ý');
}
}
}

View File

@@ -1,16 +0,0 @@
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,
) {}
}

View File

@@ -1,81 +0,0 @@
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ý');
}
}
}

View File

@@ -1,44 +0,0 @@
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,
};
}
}

View File

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

View File

@@ -1,48 +0,0 @@
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,
}));
}
}

View File

@@ -1,5 +0,0 @@
export class GetPropertyDocumentsQuery {
constructor(
public readonly propertyId: string,
) {}
}

View File

@@ -1,47 +0,0 @@
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 {}

View File

@@ -1,279 +0,0 @@
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);
});
});
});

View File

@@ -1 +0,0 @@
export { PropertyDocumentEntity, type PropertyDocumentProps, type DocumentType, type DocumentVerificationStatus } from './property-document.entity';

View File

@@ -1,106 +0,0 @@
import { BaseEntity } from '@modules/shared';
export type DocumentType = 'SO_DO' | 'SO_HONG' | 'GCNQSD' | 'OTHER';
export type DocumentVerificationStatus = 'PENDING_REVIEW' | 'APPROVED' | 'REJECTED';
export interface PropertyDocumentProps {
propertyId: string;
uploadedById: string;
documentType: DocumentType;
status: DocumentVerificationStatus;
url: string;
fileName: string;
mimeType: string;
fileSizeBytes: number;
description: string | null;
rejectionReason: string | null;
reviewedById: string | null;
reviewedAt: Date | null;
}
export class PropertyDocumentEntity extends BaseEntity<string> {
private _propertyId: string;
private _uploadedById: string;
private _documentType: DocumentType;
private _status: DocumentVerificationStatus;
private _url: string;
private _fileName: string;
private _mimeType: string;
private _fileSizeBytes: number;
private _description: string | null;
private _rejectionReason: string | null;
private _reviewedById: string | null;
private _reviewedAt: Date | null;
constructor(id: string, props: PropertyDocumentProps, createdAt?: Date, updatedAt?: Date) {
super(id, createdAt, updatedAt);
this._propertyId = props.propertyId;
this._uploadedById = props.uploadedById;
this._documentType = props.documentType;
this._status = props.status;
this._url = props.url;
this._fileName = props.fileName;
this._mimeType = props.mimeType;
this._fileSizeBytes = props.fileSizeBytes;
this._description = props.description;
this._rejectionReason = props.rejectionReason;
this._reviewedById = props.reviewedById;
this._reviewedAt = props.reviewedAt;
}
get propertyId(): string { return this._propertyId; }
get uploadedById(): string { return this._uploadedById; }
get documentType(): DocumentType { return this._documentType; }
get status(): DocumentVerificationStatus { return this._status; }
get url(): string { return this._url; }
get fileName(): string { return this._fileName; }
get mimeType(): string { return this._mimeType; }
get fileSizeBytes(): number { return this._fileSizeBytes; }
get description(): string | null { return this._description; }
get rejectionReason(): string | null { return this._rejectionReason; }
get reviewedById(): string | null { return this._reviewedById; }
get reviewedAt(): Date | null { return this._reviewedAt; }
approve(reviewerId: string): void {
this._status = 'APPROVED';
this._reviewedById = reviewerId;
this._reviewedAt = new Date();
this._rejectionReason = null;
this.updatedAt = new Date();
}
reject(reviewerId: string, reason: string): void {
this._status = 'REJECTED';
this._reviewedById = reviewerId;
this._reviewedAt = new Date();
this._rejectionReason = reason;
this.updatedAt = new Date();
}
static createNew(
id: string,
propertyId: string,
uploadedById: string,
documentType: DocumentType,
url: string,
fileName: string,
mimeType: string,
fileSizeBytes: number,
description?: string,
): PropertyDocumentEntity {
return new PropertyDocumentEntity(id, {
propertyId,
uploadedById,
documentType,
status: 'PENDING_REVIEW',
url,
fileName,
mimeType,
fileSizeBytes,
description: description ?? null,
rejectionReason: null,
reviewedById: null,
reviewedAt: null,
});
}
}

View File

@@ -1 +0,0 @@
export { PROPERTY_DOCUMENT_REPOSITORY, type IPropertyDocumentRepository } from './property-document.repository';

View File

@@ -1,13 +0,0 @@
import { type PropertyDocumentEntity } from '../entities/property-document.entity';
export const PROPERTY_DOCUMENT_REPOSITORY = Symbol('PROPERTY_DOCUMENT_REPOSITORY');
export interface IPropertyDocumentRepository {
findById(id: string): Promise<PropertyDocumentEntity | null>;
findByPropertyId(propertyId: string): Promise<PropertyDocumentEntity[]>;
save(doc: PropertyDocumentEntity): Promise<void>;
update(doc: PropertyDocumentEntity): Promise<void>;
delete(id: string): Promise<void>;
findPendingReview(page: number, limit: number): Promise<{ items: PropertyDocumentEntity[]; total: number }>;
countApprovedByPropertyId(propertyId: string): Promise<number>;
}

View File

@@ -1,3 +0,0 @@
export { DocumentsModule } from './documents.module';
export { PROPERTY_DOCUMENT_REPOSITORY, type IPropertyDocumentRepository } from './domain/repositories/property-document.repository';
export { PropertyDocumentEntity, type DocumentType, type DocumentVerificationStatus } from './domain/entities/property-document.entity';

View File

@@ -1,317 +0,0 @@
import { type DocumentType, type DocumentVerificationStatus } from '@prisma/client';
import { PrismaPropertyDocumentRepository } from '../repositories/prisma-property-document.repository';
describe('PrismaPropertyDocumentRepository', () => {
let repository: PrismaPropertyDocumentRepository;
let mockPrisma: {
propertyDocument: {
findUnique: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
count: ReturnType<typeof vi.fn>;
};
$transaction: ReturnType<typeof vi.fn>;
};
const now = new Date('2026-04-01T10:00:00Z');
const later = new Date('2026-04-01T10:05:00Z');
const mockPrismaDoc = {
id: 'doc-1',
propertyId: 'prop-1',
uploadedById: 'user-1',
documentType: 'SO_DO' as DocumentType,
status: 'PENDING_REVIEW' as DocumentVerificationStatus,
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,
createdAt: now,
updatedAt: later,
};
beforeEach(() => {
mockPrisma = {
propertyDocument: {
findUnique: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
$transaction: vi.fn(),
};
repository = new PrismaPropertyDocumentRepository(mockPrisma as any);
});
describe('findById', () => {
it('returns domain entity when document exists', async () => {
mockPrisma.propertyDocument.findUnique.mockResolvedValue(mockPrismaDoc);
const result = await repository.findById('doc-1');
expect(mockPrisma.propertyDocument.findUnique).toHaveBeenCalledWith({
where: { id: 'doc-1' },
});
expect(result).not.toBeNull();
expect(result!.id).toBe('doc-1');
expect(result!.propertyId).toBe('prop-1');
expect(result!.uploadedById).toBe('user-1');
expect(result!.documentType).toBe('SO_DO');
expect(result!.status).toBe('PENDING_REVIEW');
expect(result!.url).toBe('http://storage.local/documents/test.pdf');
expect(result!.fileName).toBe('sodo.pdf');
expect(result!.mimeType).toBe('application/pdf');
expect(result!.fileSizeBytes).toBe(1024);
expect(result!.description).toBe('So do chinh chu');
expect(result!.rejectionReason).toBeNull();
expect(result!.reviewedById).toBeNull();
expect(result!.reviewedAt).toBeNull();
});
it('returns null when document does not exist', async () => {
mockPrisma.propertyDocument.findUnique.mockResolvedValue(null);
const result = await repository.findById('nonexistent');
expect(result).toBeNull();
});
it('preserves createdAt and updatedAt timestamps', async () => {
mockPrisma.propertyDocument.findUnique.mockResolvedValue(mockPrismaDoc);
const result = await repository.findById('doc-1');
expect(result!.createdAt).toEqual(now);
expect(result!.updatedAt).toEqual(later);
});
});
describe('findByPropertyId', () => {
it('returns array of domain entities ordered by createdAt desc', async () => {
const docs = [
{ ...mockPrismaDoc, id: 'doc-2', createdAt: later },
{ ...mockPrismaDoc, id: 'doc-1', createdAt: now },
];
mockPrisma.propertyDocument.findMany.mockResolvedValue(docs);
const result = await repository.findByPropertyId('prop-1');
expect(mockPrisma.propertyDocument.findMany).toHaveBeenCalledWith({
where: { propertyId: 'prop-1' },
orderBy: { createdAt: 'desc' },
});
expect(result).toHaveLength(2);
expect(result[0]!.id).toBe('doc-2');
expect(result[1]!.id).toBe('doc-1');
});
it('returns empty array when no documents exist', async () => {
mockPrisma.propertyDocument.findMany.mockResolvedValue([]);
const result = await repository.findByPropertyId('prop-no-docs');
expect(result).toHaveLength(0);
});
});
describe('save', () => {
it('persists a new document with correct field mapping', async () => {
mockPrisma.propertyDocument.create.mockResolvedValue(mockPrismaDoc);
const { PropertyDocumentEntity } = await import('../../domain/entities/property-document.entity');
const entity = PropertyDocumentEntity.createNew(
'doc-1', 'prop-1', 'user-1', 'SO_DO',
'http://storage.local/documents/test.pdf',
'sodo.pdf', 'application/pdf', 1024, 'So do chinh chu',
);
await repository.save(entity);
expect(mockPrisma.propertyDocument.create).toHaveBeenCalledWith({
data: {
id: 'doc-1',
propertyId: 'prop-1',
uploadedById: 'user-1',
documentType: 'SO_DO',
status: 'PENDING_REVIEW',
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,
},
});
});
});
describe('update', () => {
it('updates status, rejectionReason, reviewedById, reviewedAt, updatedAt', async () => {
mockPrisma.propertyDocument.update.mockResolvedValue(mockPrismaDoc);
const { PropertyDocumentEntity } = await import('../../domain/entities/property-document.entity');
const entity = PropertyDocumentEntity.createNew(
'doc-1', 'prop-1', 'user-1', 'SO_DO',
'http://storage.local/documents/test.pdf',
'sodo.pdf', 'application/pdf', 1024,
);
entity.approve('admin-1');
await repository.update(entity);
expect(mockPrisma.propertyDocument.update).toHaveBeenCalledWith({
where: { id: 'doc-1' },
data: {
status: 'APPROVED',
rejectionReason: null,
reviewedById: 'admin-1',
reviewedAt: expect.any(Date),
updatedAt: expect.any(Date),
},
});
});
});
describe('delete', () => {
it('deletes document by id', async () => {
mockPrisma.propertyDocument.delete.mockResolvedValue(mockPrismaDoc);
await repository.delete('doc-1');
expect(mockPrisma.propertyDocument.delete).toHaveBeenCalledWith({
where: { id: 'doc-1' },
});
});
});
describe('findPendingReview', () => {
it('returns paginated items and total count', async () => {
const pendingDocs = [
{ ...mockPrismaDoc, id: 'doc-1' },
{ ...mockPrismaDoc, id: 'doc-2' },
];
mockPrisma.$transaction.mockResolvedValue([pendingDocs, 5]);
const result = await repository.findPendingReview(1, 2);
expect(mockPrisma.$transaction).toHaveBeenCalled();
expect(result.items).toHaveLength(2);
expect(result.total).toBe(5);
expect(result.items[0]!.id).toBe('doc-1');
expect(result.items[1]!.id).toBe('doc-2');
});
it('applies correct pagination (page 2, limit 10)', async () => {
mockPrisma.$transaction.mockImplementation(async (queries: unknown[]) => {
// The transaction receives an array of promises
return Promise.all(queries as Promise<unknown>[]);
});
mockPrisma.propertyDocument.findMany.mockResolvedValue([]);
mockPrisma.propertyDocument.count.mockResolvedValue(0);
await repository.findPendingReview(2, 10);
expect(mockPrisma.propertyDocument.findMany).toHaveBeenCalledWith({
where: { status: 'PENDING_REVIEW' },
orderBy: { createdAt: 'asc' },
skip: 10,
take: 10,
});
});
it('returns empty items when no pending documents', async () => {
mockPrisma.$transaction.mockResolvedValue([[], 0]);
const result = await repository.findPendingReview(1, 20);
expect(result.items).toHaveLength(0);
expect(result.total).toBe(0);
});
});
describe('countApprovedByPropertyId', () => {
it('counts approved documents for a property', async () => {
mockPrisma.propertyDocument.count.mockResolvedValue(3);
const result = await repository.countApprovedByPropertyId('prop-1');
expect(mockPrisma.propertyDocument.count).toHaveBeenCalledWith({
where: { propertyId: 'prop-1', status: 'APPROVED' },
});
expect(result).toBe(3);
});
it('returns 0 when no approved documents', async () => {
mockPrisma.propertyDocument.count.mockResolvedValue(0);
const result = await repository.countApprovedByPropertyId('prop-no-approved');
expect(result).toBe(0);
});
});
describe('toDomain mapping', () => {
it('correctly maps all document types', async () => {
const docTypes: DocumentType[] = ['SO_DO', 'SO_HONG', 'GCNQSD', 'OTHER'];
for (const dt of docTypes) {
mockPrisma.propertyDocument.findUnique.mockResolvedValue({
...mockPrismaDoc,
documentType: dt,
});
const result = await repository.findById('doc-1');
expect(result!.documentType).toBe(dt);
}
});
it('correctly maps all verification statuses', async () => {
const statuses: DocumentVerificationStatus[] = ['PENDING_REVIEW', 'APPROVED', 'REJECTED'];
for (const st of statuses) {
mockPrisma.propertyDocument.findUnique.mockResolvedValue({
...mockPrismaDoc,
status: st,
});
const result = await repository.findById('doc-1');
expect(result!.status).toBe(st);
}
});
it('preserves null description', async () => {
mockPrisma.propertyDocument.findUnique.mockResolvedValue({
...mockPrismaDoc,
description: null,
});
const result = await repository.findById('doc-1');
expect(result!.description).toBeNull();
});
it('preserves rejection reason and reviewer info', async () => {
const reviewedAt = new Date('2026-04-01T12:00:00Z');
mockPrisma.propertyDocument.findUnique.mockResolvedValue({
...mockPrismaDoc,
status: 'REJECTED',
rejectionReason: 'Anh khong ro',
reviewedById: 'admin-1',
reviewedAt,
});
const result = await repository.findById('doc-1');
expect(result!.status).toBe('REJECTED');
expect(result!.rejectionReason).toBe('Anh khong ro');
expect(result!.reviewedById).toBe('admin-1');
expect(result!.reviewedAt).toEqual(reviewedAt);
});
});
});

View File

@@ -1,97 +0,0 @@
import { Injectable } from '@nestjs/common';
import { type PropertyDocument as PrismaPropertyDocument, type DocumentType, type DocumentVerificationStatus } from '@prisma/client';
import { PrismaService } from '@modules/shared';
import { PropertyDocumentEntity, type PropertyDocumentProps } from '../../domain/entities/property-document.entity';
import { type IPropertyDocumentRepository } from '../../domain/repositories/property-document.repository';
@Injectable()
export class PrismaPropertyDocumentRepository implements IPropertyDocumentRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<PropertyDocumentEntity | null> {
const row = await this.prisma.propertyDocument.findUnique({ where: { id } });
return row ? this.toDomain(row) : null;
}
async findByPropertyId(propertyId: string): Promise<PropertyDocumentEntity[]> {
const rows = await this.prisma.propertyDocument.findMany({
where: { propertyId },
orderBy: { createdAt: 'desc' },
});
return rows.map((r) => this.toDomain(r));
}
async save(doc: PropertyDocumentEntity): Promise<void> {
await this.prisma.propertyDocument.create({
data: {
id: doc.id,
propertyId: doc.propertyId,
uploadedById: doc.uploadedById,
documentType: doc.documentType as DocumentType,
status: doc.status as DocumentVerificationStatus,
url: doc.url,
fileName: doc.fileName,
mimeType: doc.mimeType,
fileSizeBytes: doc.fileSizeBytes,
description: doc.description,
rejectionReason: doc.rejectionReason,
reviewedById: doc.reviewedById,
reviewedAt: doc.reviewedAt,
},
});
}
async update(doc: PropertyDocumentEntity): Promise<void> {
await this.prisma.propertyDocument.update({
where: { id: doc.id },
data: {
status: doc.status as DocumentVerificationStatus,
rejectionReason: doc.rejectionReason,
reviewedById: doc.reviewedById,
reviewedAt: doc.reviewedAt,
updatedAt: doc.updatedAt,
},
});
}
async delete(id: string): Promise<void> {
await this.prisma.propertyDocument.delete({ where: { id } });
}
async findPendingReview(page: number, limit: number): Promise<{ items: PropertyDocumentEntity[]; total: number }> {
const [rows, total] = await this.prisma.$transaction([
this.prisma.propertyDocument.findMany({
where: { status: 'PENDING_REVIEW' },
orderBy: { createdAt: 'asc' },
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.propertyDocument.count({ where: { status: 'PENDING_REVIEW' } }),
]);
return { items: rows.map((r) => this.toDomain(r)), total };
}
async countApprovedByPropertyId(propertyId: string): Promise<number> {
return this.prisma.propertyDocument.count({
where: { propertyId, status: 'APPROVED' },
});
}
private toDomain(row: PrismaPropertyDocument): PropertyDocumentEntity {
const props: PropertyDocumentProps = {
propertyId: row.propertyId,
uploadedById: row.uploadedById,
documentType: row.documentType,
status: row.status,
url: row.url,
fileName: row.fileName,
mimeType: row.mimeType,
fileSizeBytes: row.fileSizeBytes,
description: row.description,
rejectionReason: row.rejectionReason,
reviewedById: row.reviewedById,
reviewedAt: row.reviewedAt,
};
return new PropertyDocumentEntity(row.id, props, row.createdAt, row.updatedAt);
}
}

View File

@@ -1,178 +0,0 @@
import { PropertyDocumentsController } from '../controllers/property-documents.controller';
describe('PropertyDocumentsController', () => {
let controller: PropertyDocumentsController;
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
let mockQueryBus: { execute: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockCommandBus = { execute: vi.fn() };
mockQueryBus = { execute: vi.fn() };
controller = new PropertyDocumentsController(
mockCommandBus as any,
mockQueryBus as any,
);
});
describe('uploadDocument', () => {
it('executes UploadDocumentCommand with correct params', async () => {
const expectedResult = { documentId: 'doc-1', url: 'http://storage.local/test.pdf' };
mockCommandBus.execute.mockResolvedValue(expectedResult);
const file = {
buffer: Buffer.from('fake'),
mimetype: 'application/pdf',
originalname: 'sodo.pdf',
size: 1024,
};
const dto = { documentType: 'SO_DO' as const };
const user = { sub: 'user-1', email: 'test@example.com', role: 'USER' };
const result = await controller.uploadDocument('prop-1', file as any, dto, user as any);
expect(result).toEqual(expectedResult);
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
const command = mockCommandBus.execute.mock.calls[0]![0];
expect(command.propertyId).toBe('prop-1');
expect(command.userId).toBe('user-1');
expect(command.documentType).toBe('SO_DO');
expect(command.file).toBe(file);
});
it('passes optional description from dto', async () => {
mockCommandBus.execute.mockResolvedValue({ documentId: 'doc-1', url: 'http://test.pdf' });
const file = {
buffer: Buffer.from('fake'),
mimetype: 'application/pdf',
originalname: 'sodo.pdf',
size: 1024,
};
const dto = { documentType: 'SO_HONG' as const, description: 'So hong chinh chu' };
const user = { sub: 'user-1', email: 'test@example.com', role: 'USER' };
await controller.uploadDocument('prop-1', file as any, dto, user as any);
const command = mockCommandBus.execute.mock.calls[0]![0];
expect(command.description).toBe('So hong chinh chu');
});
});
describe('getPropertyDocuments', () => {
it('executes GetPropertyDocumentsQuery with propertyId', async () => {
const expectedDocs = [
{ id: 'doc-1', propertyId: 'prop-1', documentType: 'SO_DO' },
{ id: 'doc-2', propertyId: 'prop-1', documentType: 'SO_HONG' },
];
mockQueryBus.execute.mockResolvedValue(expectedDocs);
const result = await controller.getPropertyDocuments('prop-1');
expect(result).toEqual(expectedDocs);
expect(mockQueryBus.execute).toHaveBeenCalledTimes(1);
const query = mockQueryBus.execute.mock.calls[0]![0];
expect(query.propertyId).toBe('prop-1');
});
it('returns empty array when no documents', async () => {
mockQueryBus.execute.mockResolvedValue([]);
const result = await controller.getPropertyDocuments('prop-empty');
expect(result).toEqual([]);
});
});
describe('getPendingDocuments', () => {
it('executes GetPendingDocumentsQuery with default page and limit', async () => {
const expectedResult = { items: [], total: 0, page: 1, limit: 20 };
mockQueryBus.execute.mockResolvedValue(expectedResult);
const result = await controller.getPendingDocuments();
expect(result).toEqual(expectedResult);
const query = mockQueryBus.execute.mock.calls[0]![0];
expect(query.page).toBe(1);
expect(query.limit).toBe(20);
});
it('parses string page and limit to integers', async () => {
mockQueryBus.execute.mockResolvedValue({ items: [], total: 0, page: 3, limit: 50 });
await controller.getPendingDocuments('3', '50');
const query = mockQueryBus.execute.mock.calls[0]![0];
expect(query.page).toBe(3);
expect(query.limit).toBe(50);
});
it('uses default page=1 when page is not provided', async () => {
mockQueryBus.execute.mockResolvedValue({ items: [], total: 0, page: 1, limit: 10 });
await controller.getPendingDocuments(undefined, '10');
const query = mockQueryBus.execute.mock.calls[0]![0];
expect(query.page).toBe(1);
expect(query.limit).toBe(10);
});
it('uses default limit=20 when limit is not provided', async () => {
mockQueryBus.execute.mockResolvedValue({ items: [], total: 0, page: 2, limit: 20 });
await controller.getPendingDocuments('2');
const query = mockQueryBus.execute.mock.calls[0]![0];
expect(query.page).toBe(2);
expect(query.limit).toBe(20);
});
});
describe('approveDocument', () => {
it('executes ApproveDocumentCommand with correct params', async () => {
const expectedResult = { documentId: 'doc-1', status: 'APPROVED', message: 'ok' };
mockCommandBus.execute.mockResolvedValue(expectedResult);
const dto = { notes: 'Giay to hop le' };
const user = { sub: 'admin-1', email: 'admin@example.com', role: 'ADMIN' };
const result = await controller.approveDocument('doc-1', dto, user as any);
expect(result).toEqual(expectedResult);
const command = mockCommandBus.execute.mock.calls[0]![0];
expect(command.documentId).toBe('doc-1');
expect(command.adminId).toBe('admin-1');
expect(command.notes).toBe('Giay to hop le');
});
it('passes undefined notes when not provided', async () => {
mockCommandBus.execute.mockResolvedValue({ documentId: 'doc-1', status: 'APPROVED', message: 'ok' });
const dto = {};
const user = { sub: 'admin-1', email: 'admin@example.com', role: 'ADMIN' };
await controller.approveDocument('doc-1', dto, user as any);
const command = mockCommandBus.execute.mock.calls[0]![0];
expect(command.notes).toBeUndefined();
});
});
describe('rejectDocument', () => {
it('executes RejectDocumentCommand with correct params', async () => {
const expectedResult = { documentId: 'doc-1', status: 'REJECTED', message: 'rejected' };
mockCommandBus.execute.mockResolvedValue(expectedResult);
const dto = { reason: 'Giay to khong ro rang' };
const user = { sub: 'admin-1', email: 'admin@example.com', role: 'ADMIN' };
const result = await controller.rejectDocument('doc-1', dto, user as any);
expect(result).toEqual(expectedResult);
const command = mockCommandBus.execute.mock.calls[0]![0];
expect(command.documentId).toBe('doc-1');
expect(command.adminId).toBe('admin-1');
expect(command.reason).toBe('Giay to khong ro rang');
});
});
});

View File

@@ -1,127 +0,0 @@
import { validate } from 'class-validator';
import { UploadDocumentDto, ApproveDocumentDto, RejectDocumentDto } from '../dto/upload-document.dto';
describe('UploadDocumentDto', () => {
it('accepts valid SO_DO document type', async () => {
const dto = new UploadDocumentDto();
dto.documentType = 'SO_DO';
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('accepts valid SO_HONG document type', async () => {
const dto = new UploadDocumentDto();
dto.documentType = 'SO_HONG';
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('accepts valid GCNQSD document type', async () => {
const dto = new UploadDocumentDto();
dto.documentType = 'GCNQSD';
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('accepts valid OTHER document type', async () => {
const dto = new UploadDocumentDto();
dto.documentType = 'OTHER';
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('rejects invalid document type', async () => {
const dto = new UploadDocumentDto();
(dto as any).documentType = 'INVALID';
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]!.property).toBe('documentType');
});
it('accepts optional description string', async () => {
const dto = new UploadDocumentDto();
dto.documentType = 'SO_DO';
dto.description = 'So do chinh chu';
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('accepts missing description', async () => {
const dto = new UploadDocumentDto();
dto.documentType = 'SO_DO';
const errors = await validate(dto);
expect(errors).toHaveLength(0);
expect(dto.description).toBeUndefined();
});
it('rejects non-string description', async () => {
const dto = new UploadDocumentDto();
dto.documentType = 'SO_DO';
(dto as any).description = 12345;
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors.some((e) => e.property === 'description')).toBe(true);
});
});
describe('ApproveDocumentDto', () => {
it('accepts optional notes string', async () => {
const dto = new ApproveDocumentDto();
dto.notes = 'Giay to hop le';
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('accepts missing notes', async () => {
const dto = new ApproveDocumentDto();
const errors = await validate(dto);
expect(errors).toHaveLength(0);
expect(dto.notes).toBeUndefined();
});
it('rejects non-string notes', async () => {
const dto = new ApproveDocumentDto();
(dto as any).notes = 999;
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]!.property).toBe('notes');
});
});
describe('RejectDocumentDto', () => {
it('accepts valid reason string', async () => {
const dto = new RejectDocumentDto();
dto.reason = 'Giay to khong ro rang';
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('rejects missing reason', async () => {
const dto = new RejectDocumentDto();
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]!.property).toBe('reason');
});
it('rejects non-string reason', async () => {
const dto = new RejectDocumentDto();
(dto as any).reason = 12345;
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]!.property).toBe('reason');
});
});

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