Compare commits
38 Commits
252f4f813b
...
25f415f3bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25f415f3bc | ||
|
|
3a9325719a | ||
|
|
430c67f244 | ||
|
|
deb04989de | ||
|
|
7ce651fce5 | ||
|
|
62a8842193 | ||
|
|
a48abf23b5 | ||
|
|
a3f0c731fe | ||
|
|
3b5da2dcf9 | ||
|
|
30d3039b94 | ||
|
|
5db3dfbda6 | ||
|
|
e78d706b42 | ||
|
|
53c33a1c50 | ||
|
|
2a69736728 | ||
|
|
d4e100a00c | ||
|
|
c920934fb6 | ||
|
|
86adcf7295 | ||
|
|
e21e096e54 | ||
|
|
8da488711b | ||
|
|
93a390efb9 | ||
|
|
ae52081d7d | ||
|
|
43f9e23b28 | ||
|
|
baaeb56849 | ||
|
|
ea5d4af30c | ||
|
|
8f8e20f4c0 | ||
|
|
89aaa25bb6 | ||
|
|
18bb6bfe17 | ||
|
|
ce781df76d | ||
|
|
cc584239b0 | ||
|
|
4400d0c123 | ||
|
|
3a5d2ca9c1 | ||
|
|
74c52198b3 | ||
|
|
8039b47795 | ||
|
|
50a0d739a7 | ||
|
|
eebe24e1ae | ||
|
|
20b79acf08 | ||
|
|
b809fabd41 | ||
|
|
92e708f17f |
31
.env.example
31
.env.example
@@ -29,8 +29,8 @@ PGBOUNCER_STATS_PASSWORD=CHANGE_ME
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
REDIS_HOST=localhost
|
REDIS_HOST=localhost
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
REDIS_PASSWORD=
|
REDIS_PASSWORD=CHANGE_ME_IN_PRODUCTION
|
||||||
REDIS_URL=redis://${REDIS_HOST}:${REDIS_PORT}
|
REDIS_URL=redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Typesense
|
# Typesense
|
||||||
@@ -44,6 +44,7 @@ TYPESENSE_API_KEY=CHANGE_ME
|
|||||||
# MinIO (S3-compatible Object Storage)
|
# MinIO (S3-compatible Object Storage)
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
MINIO_ENDPOINT=localhost
|
MINIO_ENDPOINT=localhost
|
||||||
|
MINIO_API_PORT=9000
|
||||||
MINIO_PORT=9000
|
MINIO_PORT=9000
|
||||||
MINIO_CONSOLE_PORT=9001
|
MINIO_CONSOLE_PORT=9001
|
||||||
MINIO_ACCESS_KEY=CHANGE_ME
|
MINIO_ACCESS_KEY=CHANGE_ME
|
||||||
@@ -127,6 +128,12 @@ ZALOPAY_KEY1=
|
|||||||
ZALOPAY_KEY2=
|
ZALOPAY_KEY2=
|
||||||
ZALOPAY_ENDPOINT=https://sb-openapi.zalopay.vn/v2
|
ZALOPAY_ENDPOINT=https://sb-openapi.zalopay.vn/v2
|
||||||
|
|
||||||
|
BANK_TRANSFER_ACCOUNT_NUMBER=
|
||||||
|
BANK_TRANSFER_BANK_NAME=
|
||||||
|
BANK_TRANSFER_ACCOUNT_HOLDER=
|
||||||
|
BANK_TRANSFER_WEBHOOK_SECRET=
|
||||||
|
BANK_TRANSFER_INSTRUCTIONS_URL=https://goodgo.vn/thanh-toan/chuyen-khoan
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Email / SMTP
|
# Email / SMTP
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -136,11 +143,31 @@ SMTP_USER=
|
|||||||
SMTP_PASS=
|
SMTP_PASS=
|
||||||
SMTP_FROM=noreply@goodgo.vn
|
SMTP_FROM=noreply@goodgo.vn
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Stringee SMS (Vietnamese SMS provider — OTP & notifications)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
STRINGEE_API_KEY=
|
||||||
|
STRINGEE_BRANDNAME=GoodGo
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Firebase Cloud Messaging (optional)
|
# Firebase Cloud Messaging (optional)
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
FIREBASE_SERVICE_ACCOUNT=
|
FIREBASE_SERVICE_ACCOUNT=
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Zalo OA Notifications (ZNS — Zalo Notification Service)
|
||||||
|
# Obtain from Zalo OA Manager: https://oa.zalo.me/manage
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
ZALO_OA_ID=
|
||||||
|
ZALO_OA_ACCESS_TOKEN=
|
||||||
|
|
||||||
|
# ZNS Template IDs (registered in Zalo OA Manager console)
|
||||||
|
ZALO_ZNS_TEMPLATE_INQUIRY=
|
||||||
|
ZALO_ZNS_TEMPLATE_PAYMENT=
|
||||||
|
ZALO_ZNS_TEMPLATE_LISTING_APPROVED=
|
||||||
|
ZALO_ZNS_TEMPLATE_LISTING_REJECTED=
|
||||||
|
ZALO_ZNS_TEMPLATE_LISTING_SOLD=
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Sentry Error Tracking
|
# Sentry Error Tracking
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -2,9 +2,9 @@ name: CodeQL Analysis
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [master]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [master]
|
||||||
schedule:
|
schedule:
|
||||||
# Run weekly on Monday at 06:17 UTC — off-peak to avoid :00/:30 congestion
|
# Run weekly on Monday at 06:17 UTC — off-peak to avoid :00/:30 congestion
|
||||||
- cron: "17 6 * * 1"
|
- cron: "17 6 * * 1"
|
||||||
|
|||||||
126
.github/workflows/deploy.yml
vendored
126
.github/workflows/deploy.yml
vendored
@@ -211,6 +211,16 @@ jobs:
|
|||||||
# Login to GHCR
|
# Login to GHCR
|
||||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
|
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
|
||||||
|
|
||||||
|
# Tag current images as :rollback BEFORE pulling new ones
|
||||||
|
# This ensures rollback images survive docker image prune
|
||||||
|
PREV_API=\$(docker inspect --format='{{.Config.Image}}' goodgo-api 2>/dev/null || echo "none")
|
||||||
|
PREV_WEB=\$(docker inspect --format='{{.Config.Image}}' goodgo-web 2>/dev/null || echo "none")
|
||||||
|
PREV_AI=\$(docker inspect --format='{{.Config.Image}}' goodgo-ai-services 2>/dev/null || echo "none")
|
||||||
|
|
||||||
|
[ "\$PREV_API" != "none" ] && docker tag "\$PREV_API" goodgo-api:rollback 2>/dev/null || true
|
||||||
|
[ "\$PREV_WEB" != "none" ] && docker tag "\$PREV_WEB" goodgo-web:rollback 2>/dev/null || true
|
||||||
|
[ "\$PREV_AI" != "none" ] && docker tag "\$PREV_AI" goodgo-ai-services:rollback 2>/dev/null || true
|
||||||
|
|
||||||
# Pull new images
|
# Pull new images
|
||||||
docker compose -f docker-compose.prod.yml pull api web ai-services
|
docker compose -f docker-compose.prod.yml pull api web ai-services
|
||||||
|
|
||||||
@@ -222,8 +232,7 @@ jobs:
|
|||||||
# Run database migrations
|
# Run database migrations
|
||||||
docker compose -f docker-compose.prod.yml exec -T api npx prisma migrate deploy
|
docker compose -f docker-compose.prod.yml exec -T api npx prisma migrate deploy
|
||||||
|
|
||||||
# Cleanup old images
|
# NOTE: docker image prune is NOT run here — it runs after smoke tests pass
|
||||||
docker image prune -f
|
|
||||||
DEPLOY_SCRIPT
|
DEPLOY_SCRIPT
|
||||||
|
|
||||||
- name: Sync Nginx configs
|
- name: Sync Nginx configs
|
||||||
@@ -280,6 +289,25 @@ jobs:
|
|||||||
chmod +x scripts/smoke-test.sh
|
chmod +x scripts/smoke-test.sh
|
||||||
./scripts/smoke-test.sh "$STAGING_URL"
|
./scripts/smoke-test.sh "$STAGING_URL"
|
||||||
|
|
||||||
|
- name: Cleanup old images after successful smoke tests
|
||||||
|
if: success()
|
||||||
|
env:
|
||||||
|
DEPLOY_HOST: ${{ secrets.STAGING_HOST }}
|
||||||
|
DEPLOY_USER: ${{ secrets.STAGING_USER }}
|
||||||
|
DEPLOY_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
|
||||||
|
chmod 600 ~/.ssh/deploy_key
|
||||||
|
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" << 'CLEANUP_SCRIPT'
|
||||||
|
cd ~/goodgo
|
||||||
|
# Remove rollback tags — no longer needed after successful smoke tests
|
||||||
|
docker rmi goodgo-api:rollback goodgo-web:rollback goodgo-ai-services:rollback 2>/dev/null || true
|
||||||
|
docker image prune -f
|
||||||
|
CLEANUP_SCRIPT
|
||||||
|
|
||||||
- name: Notify on success
|
- name: Notify on success
|
||||||
if: success()
|
if: success()
|
||||||
env:
|
env:
|
||||||
@@ -329,22 +357,38 @@ jobs:
|
|||||||
DEPLOY_HOST: ${{ secrets.STAGING_HOST }}
|
DEPLOY_HOST: ${{ secrets.STAGING_HOST }}
|
||||||
DEPLOY_USER: ${{ secrets.STAGING_USER }}
|
DEPLOY_USER: ${{ secrets.STAGING_USER }}
|
||||||
DEPLOY_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
DEPLOY_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||||
|
REGISTRY_URL: ${{ env.REGISTRY_URL }}
|
||||||
|
IMAGE_TAG: ${{ github.sha }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
|
echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
|
||||||
chmod 600 ~/.ssh/deploy_key
|
chmod 600 ~/.ssh/deploy_key
|
||||||
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" << 'ROLLBACK_SCRIPT'
|
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" << ROLLBACK_SCRIPT
|
||||||
cd ~/goodgo
|
cd ~/goodgo
|
||||||
|
|
||||||
echo "Rolling back staging to previous container images..."
|
echo "Rolling back staging using :rollback tagged images..."
|
||||||
|
|
||||||
# Stop current containers and restart with previous images
|
REGISTRY_URL="${REGISTRY_URL}"
|
||||||
# Docker keeps the previous image layer; compose down + up
|
IMAGE_TAG="${IMAGE_TAG}"
|
||||||
# reverts to the last-known-good state before the pull
|
|
||||||
docker compose -f docker-compose.prod.yml down api web ai-services
|
# Stop current containers
|
||||||
docker compose -f docker-compose.prod.yml up -d --wait api web ai-services
|
docker compose -f docker-compose.prod.yml stop api web ai-services
|
||||||
|
|
||||||
|
# Retag :rollback images to match compose image template so compose uses them
|
||||||
|
for svc in goodgo-api goodgo-web goodgo-ai-services; do
|
||||||
|
if docker image inspect "\${svc}:rollback" > /dev/null 2>&1; then
|
||||||
|
echo "Restoring \${svc} from :rollback tag"
|
||||||
|
docker tag "\${svc}:rollback" "\${REGISTRY_URL}/\${svc}:\${IMAGE_TAG}"
|
||||||
|
else
|
||||||
|
echo "WARNING: No rollback image for \${svc}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Restart with rollback images (now tagged to match compose template)
|
||||||
|
export IMAGE_TAG REGISTRY_URL
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --no-deps --wait api web ai-services
|
||||||
|
|
||||||
echo "Rollback complete. Verifying health..."
|
echo "Rollback complete. Verifying health..."
|
||||||
sleep 5
|
sleep 5
|
||||||
@@ -363,7 +407,7 @@ jobs:
|
|||||||
\"type\": \"section\",
|
\"type\": \"section\",
|
||||||
\"text\": {
|
\"text\": {
|
||||||
\"type\": \"mrkdwn\",
|
\"type\": \"mrkdwn\",
|
||||||
\"text\": \":warning: *Staging Rollback Triggered*\n*Commit:* \`${{ github.sha }}\`\n*Branch:* \`${{ github.ref_name }}\`\n*Reason:* Smoke tests failed after deploy\n*Action:* Reverted to previous container images\n*Run:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View logs>\"
|
\"text\": \":warning: *Staging Rollback Triggered*\n*Commit:* \`${{ github.sha }}\`\n*Branch:* \`${{ github.ref_name }}\`\n*Reason:* Smoke tests failed after deploy\n*Action:* Reverted to previous container images using :rollback tags\n*Run:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View logs>\"
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
}"
|
}"
|
||||||
@@ -404,6 +448,15 @@ jobs:
|
|||||||
|
|
||||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
|
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
|
||||||
|
|
||||||
|
# Tag current images as :rollback BEFORE pulling new ones
|
||||||
|
PREV_API=\$(docker inspect --format='{{.Config.Image}}' goodgo-api 2>/dev/null || echo "none")
|
||||||
|
PREV_WEB=\$(docker inspect --format='{{.Config.Image}}' goodgo-web 2>/dev/null || echo "none")
|
||||||
|
PREV_AI=\$(docker inspect --format='{{.Config.Image}}' goodgo-ai-services 2>/dev/null || echo "none")
|
||||||
|
|
||||||
|
[ "\$PREV_API" != "none" ] && docker tag "\$PREV_API" goodgo-api:rollback 2>/dev/null || true
|
||||||
|
[ "\$PREV_WEB" != "none" ] && docker tag "\$PREV_WEB" goodgo-web:rollback 2>/dev/null || true
|
||||||
|
[ "\$PREV_AI" != "none" ] && docker tag "\$PREV_AI" goodgo-ai-services:rollback 2>/dev/null || true
|
||||||
|
|
||||||
docker compose -f docker-compose.prod.yml pull api web ai-services
|
docker compose -f docker-compose.prod.yml pull api web ai-services
|
||||||
|
|
||||||
# Rolling update with health checks
|
# Rolling update with health checks
|
||||||
@@ -413,7 +466,7 @@ jobs:
|
|||||||
|
|
||||||
docker compose -f docker-compose.prod.yml exec -T api npx prisma migrate deploy
|
docker compose -f docker-compose.prod.yml exec -T api npx prisma migrate deploy
|
||||||
|
|
||||||
docker image prune -f
|
# NOTE: docker image prune is NOT run here — it runs after smoke tests pass
|
||||||
DEPLOY_SCRIPT
|
DEPLOY_SCRIPT
|
||||||
|
|
||||||
- name: Sync Nginx configs (production)
|
- name: Sync Nginx configs (production)
|
||||||
@@ -464,6 +517,25 @@ jobs:
|
|||||||
chmod +x scripts/smoke-test.sh
|
chmod +x scripts/smoke-test.sh
|
||||||
./scripts/smoke-test.sh "$PRODUCTION_URL"
|
./scripts/smoke-test.sh "$PRODUCTION_URL"
|
||||||
|
|
||||||
|
- name: Cleanup old images after successful smoke tests
|
||||||
|
if: success()
|
||||||
|
env:
|
||||||
|
DEPLOY_HOST: ${{ secrets.PRODUCTION_HOST }}
|
||||||
|
DEPLOY_USER: ${{ secrets.PRODUCTION_USER }}
|
||||||
|
DEPLOY_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
|
||||||
|
chmod 600 ~/.ssh/deploy_key
|
||||||
|
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" << 'CLEANUP_SCRIPT'
|
||||||
|
cd ~/goodgo
|
||||||
|
# Remove rollback tags — no longer needed after successful smoke tests
|
||||||
|
docker rmi goodgo-api:rollback goodgo-web:rollback goodgo-ai-services:rollback 2>/dev/null || true
|
||||||
|
docker image prune -f
|
||||||
|
CLEANUP_SCRIPT
|
||||||
|
|
||||||
- name: Notify on success
|
- name: Notify on success
|
||||||
if: success()
|
if: success()
|
||||||
env:
|
env:
|
||||||
@@ -495,22 +567,38 @@ jobs:
|
|||||||
DEPLOY_HOST: ${{ secrets.PRODUCTION_HOST }}
|
DEPLOY_HOST: ${{ secrets.PRODUCTION_HOST }}
|
||||||
DEPLOY_USER: ${{ secrets.PRODUCTION_USER }}
|
DEPLOY_USER: ${{ secrets.PRODUCTION_USER }}
|
||||||
DEPLOY_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
|
DEPLOY_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
|
||||||
|
REGISTRY_URL: ${{ env.REGISTRY_URL }}
|
||||||
|
IMAGE_TAG: ${{ github.sha }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
|
echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
|
||||||
chmod 600 ~/.ssh/deploy_key
|
chmod 600 ~/.ssh/deploy_key
|
||||||
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" << 'ROLLBACK_SCRIPT'
|
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" << ROLLBACK_SCRIPT
|
||||||
cd ~/goodgo
|
cd ~/goodgo
|
||||||
|
|
||||||
echo "Rolling back to previous container images..."
|
echo "Rolling back production using :rollback tagged images..."
|
||||||
|
|
||||||
# Stop current containers and restart with previous images
|
REGISTRY_URL="${REGISTRY_URL}"
|
||||||
# Docker keeps the previous image layer; compose down + up
|
IMAGE_TAG="${IMAGE_TAG}"
|
||||||
# reverts to the last-known-good state before the pull
|
|
||||||
docker compose -f docker-compose.prod.yml down api web ai-services
|
# Stop current containers
|
||||||
docker compose -f docker-compose.prod.yml up -d --wait api web ai-services
|
docker compose -f docker-compose.prod.yml stop api web ai-services
|
||||||
|
|
||||||
|
# Retag :rollback images to match compose image template so compose uses them
|
||||||
|
for svc in goodgo-api goodgo-web goodgo-ai-services; do
|
||||||
|
if docker image inspect "\${svc}:rollback" > /dev/null 2>&1; then
|
||||||
|
echo "Restoring \${svc} from :rollback tag"
|
||||||
|
docker tag "\${svc}:rollback" "\${REGISTRY_URL}/\${svc}:\${IMAGE_TAG}"
|
||||||
|
else
|
||||||
|
echo "WARNING: No rollback image for \${svc}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Restart with rollback images (now tagged to match compose template)
|
||||||
|
export IMAGE_TAG REGISTRY_URL
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --no-deps --wait api web ai-services
|
||||||
|
|
||||||
echo "Rollback complete. Verifying health..."
|
echo "Rollback complete. Verifying health..."
|
||||||
sleep 5
|
sleep 5
|
||||||
@@ -529,7 +617,7 @@ jobs:
|
|||||||
\"type\": \"section\",
|
\"type\": \"section\",
|
||||||
\"text\": {
|
\"text\": {
|
||||||
\"type\": \"mrkdwn\",
|
\"type\": \"mrkdwn\",
|
||||||
\"text\": \":warning: *Production Rollback Triggered*\n*Commit:* \`${{ github.sha }}\`\n*Reason:* Smoke tests failed after deploy\n*Action:* Reverted to previous container images\n*Run:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View logs>\"
|
\"text\": \":warning: *Production Rollback Triggered*\n*Commit:* \`${{ github.sha }}\`\n*Reason:* Smoke tests failed after deploy\n*Action:* Reverted to previous container images using :rollback tags\n*Run:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View logs>\"
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
}"
|
}"
|
||||||
|
|||||||
4
.github/workflows/security.yml
vendored
4
.github/workflows/security.yml
vendored
@@ -2,9 +2,9 @@ name: Security Scanning
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [master]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [master]
|
||||||
schedule:
|
schedule:
|
||||||
# Run daily at 05:43 UTC — catch new CVEs early
|
# Run daily at 05:43 UTC — catch new CVEs early
|
||||||
- cron: "43 5 * * *"
|
- cron: "43 5 * * *"
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Pino structured JSON logging with correlation IDs
|
- Pino structured JSON logging with correlation IDs
|
||||||
- Prisma ORM with migration system and seed data (Ho Chi Minh City districts/wards, sample properties, subscription plans)
|
- Prisma ORM with migration system and seed data (Ho Chi Minh City districts/wards, sample properties, subscription plans)
|
||||||
|
|
||||||
#### Frontend (Next.js 14)
|
#### Frontend (Next.js 15)
|
||||||
- App Router with Tailwind CSS and Zustand state management
|
- App Router with Tailwind CSS and Zustand state management
|
||||||
- Property search page with Mapbox GL map integration
|
- Property search page with Mapbox GL map integration
|
||||||
- Listing detail pages with media gallery
|
- Listing detail pages with media gallery
|
||||||
|
|||||||
15
CLAUDE.md
15
CLAUDE.md
@@ -15,8 +15,9 @@ pnpm dev # Start all apps (API :3001, Web :3000)
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- **apps/api** — NestJS backend (CQRS, DDD, clean architecture)
|
- **apps/api** — NestJS backend (CQRS, DDD, clean architecture)
|
||||||
- **apps/web** — Next.js 14 frontend (App Router, Tailwind, Zustand)
|
- **apps/web** — Next.js 15 frontend (App Router, Tailwind, Zustand)
|
||||||
- **libs/mcp-servers** — MCP tool server library
|
- **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
|
- **prisma/** — Schema, migrations, seed scripts
|
||||||
- **e2e/** — Playwright E2E tests (API + Web projects)
|
- **e2e/** — Playwright E2E tests (API + Web projects)
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ pnpm dev # Start all apps (API :3001, Web :3000)
|
|||||||
|
|
||||||
- **Runtime**: Node.js >= 22, pnpm 10
|
- **Runtime**: Node.js >= 22, pnpm 10
|
||||||
- **Backend**: NestJS, Prisma ORM, PostgreSQL 16 + PostGIS, Redis
|
- **Backend**: NestJS, Prisma ORM, PostgreSQL 16 + PostGIS, Redis
|
||||||
- **Frontend**: Next.js 14, React 18, Tailwind CSS 3, Zustand, Mapbox GL
|
- **Frontend**: Next.js 15, React 18, Tailwind CSS 3, Zustand, Mapbox GL
|
||||||
- **Testing**: Vitest (unit), Playwright (E2E)
|
- **Testing**: Vitest (unit), Playwright (E2E)
|
||||||
- **CI**: GitHub Actions (lint → typecheck → test → build)
|
- **CI**: GitHub Actions (lint → typecheck → test → build)
|
||||||
|
|
||||||
@@ -63,6 +64,14 @@ apps/api/src/modules/
|
|||||||
|
|
||||||
Each module follows DDD layers: `domain/` → `application/` → `infrastructure/` → `presentation/`.
|
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
|
## Database
|
||||||
|
|
||||||
- PostgreSQL 16 with PostGIS extension for geospatial queries
|
- PostgreSQL 16 with PostGIS extension for geospatial queries
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Vietnam's intelligent real estate platform — property search, AI-powered valua
|
|||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
|-------|-----------|
|
|-------|-----------|
|
||||||
| **Backend** | NestJS 11, TypeScript, Prisma ORM, CQRS |
|
| **Backend** | NestJS 11, TypeScript, Prisma ORM, CQRS |
|
||||||
| **Frontend** | Next.js 14, React 18, Tailwind CSS, Zustand |
|
| **Frontend** | Next.js 15, React 18, Tailwind CSS, Zustand |
|
||||||
| **Database** | PostgreSQL 16 + PostGIS 3.4 |
|
| **Database** | PostgreSQL 16 + PostGIS 3.4 |
|
||||||
| **Search** | Typesense 27 |
|
| **Search** | Typesense 27 |
|
||||||
| **Cache/Queue** | Redis 7 |
|
| **Cache/Queue** | Redis 7 |
|
||||||
@@ -21,7 +21,7 @@ Vietnam's intelligent real estate platform — property search, AI-powered valua
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────┐ ┌──────────────┐ ┌──────────────────┐
|
┌─────────────┐ ┌──────────────┐ ┌──────────────────┐
|
||||||
│ Next.js 14 │────▶│ NestJS API │────▶│ PostgreSQL + │
|
│ Next.js 15 │────▶│ NestJS API │────▶│ PostgreSQL + │
|
||||||
│ (Web App) │ │ (REST) │ │ PostGIS │
|
│ (Web App) │ │ (REST) │ │ PostGIS │
|
||||||
└─────────────┘ └──────┬───────┘ └──────────────────┘
|
└─────────────┘ └──────┬───────┘ └──────────────────┘
|
||||||
│
|
│
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
* npx tsx scripts/seed-with-auth.ts
|
* npx tsx scripts/seed-with-auth.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as bcrypt from 'bcrypt';
|
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import { PrismaClient, UserRole, KYCStatus } from '@prisma/client';
|
import { PrismaClient, UserRole, type KYCStatus } from '@prisma/client';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,11 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.89.0",
|
||||||
"@aws-sdk/client-s3": "^3.1026.0",
|
"@aws-sdk/client-s3": "^3.1026.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1026.0",
|
"@aws-sdk/s3-request-presigner": "^3.1026.0",
|
||||||
"@goodgo/mcp-servers": "workspace:*",
|
"@goodgo/mcp-servers": "workspace:*",
|
||||||
|
"@nestjs/bullmq": "^11.0.4",
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
"@nestjs/config": "^4.0.4",
|
"@nestjs/config": "^4.0.4",
|
||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/core": "^11.0.0",
|
||||||
@@ -24,10 +26,12 @@
|
|||||||
"@nestjs/jwt": "^11.0.2",
|
"@nestjs/jwt": "^11.0.2",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
|
"@nestjs/platform-socket.io": "^11.1.19",
|
||||||
"@nestjs/schedule": "^6.1.1",
|
"@nestjs/schedule": "^6.1.1",
|
||||||
"@nestjs/swagger": "^11.2.7",
|
"@nestjs/swagger": "^11.2.7",
|
||||||
"@nestjs/terminus": "^11.1.1",
|
"@nestjs/terminus": "^11.1.1",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
|
"@nestjs/websockets": "^11.1.19",
|
||||||
"@paralleldrive/cuid2": "^3.3.0",
|
"@paralleldrive/cuid2": "^3.3.0",
|
||||||
"@prisma/adapter-pg": "^7.7.0",
|
"@prisma/adapter-pg": "^7.7.0",
|
||||||
"@prisma/client": "^7.7.0",
|
"@prisma/client": "^7.7.0",
|
||||||
@@ -35,6 +39,7 @@
|
|||||||
"@sentry/profiling-node": "^10.47.0",
|
"@sentry/profiling-node": "^10.47.0",
|
||||||
"@willsoto/nestjs-prometheus": "^6.1.0",
|
"@willsoto/nestjs-prometheus": "^6.1.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
"bullmq": "^5.74.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
@@ -52,10 +57,12 @@
|
|||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "^13.0.0",
|
||||||
"prom-client": "^15.1.3",
|
"prom-client": "^15.1.3",
|
||||||
|
"puppeteer": "^24.41.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
"sanitize-html": "^2.17.2",
|
"sanitize-html": "^2.17.2",
|
||||||
|
"socket.io": "^4.8.3",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"typesense": "^3.0.5"
|
"typesense": "^3.0.5"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
import { type MiddlewareConsumer, Module, type NestModule, RequestMethod } from '@nestjs/common';
|
import { type MiddlewareConsumer, Module, type NestModule, RequestMethod } from '@nestjs/common';
|
||||||
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
import { CqrsModule } from '@nestjs/cqrs';
|
import { CqrsModule } from '@nestjs/cqrs';
|
||||||
@@ -9,13 +10,16 @@ import { AgentsModule } from '@modules/agents';
|
|||||||
import { AnalyticsModule } from '@modules/analytics';
|
import { AnalyticsModule } from '@modules/analytics';
|
||||||
import { AuthModule } from '@modules/auth';
|
import { AuthModule } from '@modules/auth';
|
||||||
import { HealthModule } from '@modules/health';
|
import { HealthModule } from '@modules/health';
|
||||||
|
import { IndustrialModule } from '@modules/industrial';
|
||||||
import { InquiriesModule } from '@modules/inquiries';
|
import { InquiriesModule } from '@modules/inquiries';
|
||||||
import { LeadsModule } from '@modules/leads';
|
import { LeadsModule } from '@modules/leads';
|
||||||
import { ListingsModule } from '@modules/listings';
|
import { ListingsModule } from '@modules/listings';
|
||||||
import { McpIntegrationModule } from '@modules/mcp';
|
import { McpIntegrationModule } from '@modules/mcp';
|
||||||
|
import { MessagingModule } from '@modules/messaging';
|
||||||
import { HttpMetricsInterceptor, MetricsModule } from '@modules/metrics';
|
import { HttpMetricsInterceptor, MetricsModule } from '@modules/metrics';
|
||||||
import { NotificationsModule } from '@modules/notifications';
|
import { NotificationsModule } from '@modules/notifications';
|
||||||
import { PaymentsModule } from '@modules/payments';
|
import { PaymentsModule } from '@modules/payments';
|
||||||
|
import { ReportsModule } from '@modules/reports';
|
||||||
import { ReviewsModule } from '@modules/reviews';
|
import { ReviewsModule } from '@modules/reviews';
|
||||||
import { SearchModule } from '@modules/search';
|
import { SearchModule } from '@modules/search';
|
||||||
import { SharedModule } from '@modules/shared';
|
import { SharedModule } from '@modules/shared';
|
||||||
@@ -23,11 +27,19 @@ import { ThrottlerBehindProxyGuard } from '@modules/shared/infrastructure/guards
|
|||||||
import { CsrfMiddleware } from '@modules/shared/infrastructure/middleware/csrf.middleware';
|
import { CsrfMiddleware } from '@modules/shared/infrastructure/middleware/csrf.middleware';
|
||||||
import { SanitizeInputMiddleware } from '@modules/shared/infrastructure/middleware/sanitize-input.middleware';
|
import { SanitizeInputMiddleware } from '@modules/shared/infrastructure/middleware/sanitize-input.middleware';
|
||||||
import { SubscriptionsModule } from '@modules/subscriptions';
|
import { SubscriptionsModule } from '@modules/subscriptions';
|
||||||
|
import { TransferModule } from '@modules/transfer';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
SentryModule.forRoot(),
|
SentryModule.forRoot(),
|
||||||
|
BullModule.forRoot({
|
||||||
|
connection: {
|
||||||
|
host: process.env['REDIS_HOST'] ?? 'localhost',
|
||||||
|
port: Number(process.env['REDIS_PORT'] ?? 6379),
|
||||||
|
password: process.env['REDIS_PASSWORD'] ?? undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
CqrsModule.forRoot(),
|
CqrsModule.forRoot(),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
SharedModule,
|
SharedModule,
|
||||||
@@ -46,6 +58,10 @@ import { AppController } from './app.controller';
|
|||||||
AnalyticsModule,
|
AnalyticsModule,
|
||||||
MetricsModule,
|
MetricsModule,
|
||||||
McpIntegrationModule,
|
McpIntegrationModule,
|
||||||
|
MessagingModule,
|
||||||
|
ReportsModule,
|
||||||
|
IndustrialModule,
|
||||||
|
TransferModule,
|
||||||
|
|
||||||
// ── Rate Limiting ──
|
// ── Rate Limiting ──
|
||||||
// Default: 60 requests per 60 seconds per IP
|
// Default: 60 requests per 60 seconds per IP
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import './instrument';
|
|||||||
|
|
||||||
import { RequestMethod, ValidationPipe } from '@nestjs/common';
|
import { RequestMethod, ValidationPipe } from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { IoAdapter } from '@nestjs/platform-socket.io';
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
@@ -58,6 +59,9 @@ async function bootstrap() {
|
|||||||
jsonDocumentUrl: 'api/v1/docs-json',
|
jsonDocumentUrl: 'api/v1/docs-json',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── WebSocket Adapter (Socket.IO) ──
|
||||||
|
app.useWebSocketAdapter(new IoAdapter(app));
|
||||||
|
|
||||||
// ── Security Headers (Helmet) ──
|
// ── Security Headers (Helmet) ──
|
||||||
app.use(
|
app.use(
|
||||||
helmet({
|
helmet({
|
||||||
@@ -68,7 +72,7 @@ async function bootstrap() {
|
|||||||
scriptSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'],
|
scriptSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'],
|
||||||
styleSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'],
|
styleSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'],
|
||||||
imgSrc: ["'self'", 'data:', 'https:', 'blob:'],
|
imgSrc: ["'self'", 'data:', 'https:', 'blob:'],
|
||||||
connectSrc: ["'self'", 'https://cdn.jsdelivr.net', 'https://api.goodgo.vn'],
|
connectSrc: ["'self'", 'https://cdn.jsdelivr.net', 'https://api.goodgo.vn', 'wss:', 'ws:'],
|
||||||
fontSrc: ["'self'", 'data:'],
|
fontSrc: ["'self'", 'data:'],
|
||||||
objectSrc: ["'none'"],
|
objectSrc: ["'none'"],
|
||||||
frameSrc: ["'none'"],
|
frameSrc: ["'none'"],
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { PlanTier } from '@prisma/client';
|
import { type PlanTier } from '@prisma/client';
|
||||||
import { DomainException, NotFoundException, ValidationException, PrismaService, LoggerService } from '@modules/shared';
|
import { DomainException, NotFoundException, ValidationException, type PrismaService, type LoggerService } from '@modules/shared';
|
||||||
import { SUBSCRIPTION_REPOSITORY, ISubscriptionRepository } from '@modules/subscriptions';
|
import { SUBSCRIPTION_REPOSITORY, type ISubscriptionRepository } from '@modules/subscriptions';
|
||||||
import { SubscriptionAdjustedEvent } from '../../../domain/events/subscription-adjusted.event';
|
import { SubscriptionAdjustedEvent } from '../../../domain/events/subscription-adjusted.event';
|
||||||
import { AdjustSubscriptionCommand } from './adjust-subscription.command';
|
import { AdjustSubscriptionCommand } from './adjust-subscription.command';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { USER_REPOSITORY, IUserRepository } from '@modules/auth';
|
import { USER_REPOSITORY, type IUserRepository } from '@modules/auth';
|
||||||
import { DomainException, NotFoundException, ValidationException, LoggerService } from '@modules/shared';
|
import { DomainException, NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
|
||||||
import { KycApprovedEvent } from '../../../domain/events/kyc-approved.event';
|
import { KycApprovedEvent } from '../../../domain/events/kyc-approved.event';
|
||||||
import { ApproveKycCommand } from './approve-kyc.command';
|
import { ApproveKycCommand } from './approve-kyc.command';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { LISTING_REPOSITORY, IListingRepository } from '@modules/listings';
|
import { LISTING_REPOSITORY, type IListingRepository } from '@modules/listings';
|
||||||
import { DomainException, NotFoundException, ValidationException, LoggerService } from '@modules/shared';
|
import { DomainException, NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
|
||||||
import { ListingApprovedEvent } from '../../../domain/events/listing-approved.event';
|
import { ListingApprovedEvent } from '../../../domain/events/listing-approved.event';
|
||||||
import { ApproveListingCommand } from './approve-listing.command';
|
import { ApproveListingCommand } from './approve-listing.command';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { USER_REPOSITORY, IUserRepository } from '@modules/auth';
|
import { USER_REPOSITORY, type IUserRepository } from '@modules/auth';
|
||||||
import { DomainException, NotFoundException, ValidationException, LoggerService } from '@modules/shared';
|
import { DomainException, NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
|
||||||
import { UserBannedEvent } from '../../../domain/events/user-banned.event';
|
import { UserBannedEvent } from '../../../domain/events/user-banned.event';
|
||||||
import { UserUnbannedEvent } from '../../../domain/events/user-unbanned.event';
|
import { UserUnbannedEvent } from '../../../domain/events/user-unbanned.event';
|
||||||
import { BanUserCommand } from './ban-user.command';
|
import { BanUserCommand } from './ban-user.command';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { LISTING_REPOSITORY, IListingRepository } from '@modules/listings';
|
import { LISTING_REPOSITORY, type IListingRepository } from '@modules/listings';
|
||||||
import { DomainException, ValidationException, LoggerService } from '@modules/shared';
|
import { DomainException, ValidationException, type LoggerService } from '@modules/shared';
|
||||||
import { ListingApprovedEvent } from '../../../domain/events/listing-approved.event';
|
import { ListingApprovedEvent } from '../../../domain/events/listing-approved.event';
|
||||||
import { ListingRejectedEvent } from '../../../domain/events/listing-rejected.event';
|
import { ListingRejectedEvent } from '../../../domain/events/listing-rejected.event';
|
||||||
import { BulkModerateListingsCommand } from './bulk-moderate-listings.command';
|
import { BulkModerateListingsCommand } from './bulk-moderate-listings.command';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { USER_REPOSITORY, IUserRepository } from '@modules/auth';
|
import { USER_REPOSITORY, type IUserRepository } from '@modules/auth';
|
||||||
import { DomainException, NotFoundException, ValidationException, LoggerService } from '@modules/shared';
|
import { DomainException, NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
|
||||||
import { KycRejectedEvent } from '../../../domain/events/kyc-rejected.event';
|
import { KycRejectedEvent } from '../../../domain/events/kyc-rejected.event';
|
||||||
import { RejectKycCommand } from './reject-kyc.command';
|
import { RejectKycCommand } from './reject-kyc.command';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { LISTING_REPOSITORY, IListingRepository } from '@modules/listings';
|
import { LISTING_REPOSITORY, type IListingRepository } from '@modules/listings';
|
||||||
import { DomainException, NotFoundException, ValidationException, LoggerService } from '@modules/shared';
|
import { DomainException, NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
|
||||||
import { ListingRejectedEvent } from '../../../domain/events/listing-rejected.event';
|
import { ListingRejectedEvent } from '../../../domain/events/listing-rejected.event';
|
||||||
import { RejectListingCommand } from './reject-listing.command';
|
import { RejectListingCommand } from './reject-listing.command';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { USER_REPOSITORY, IUserRepository } from '@modules/auth';
|
import { USER_REPOSITORY, type IUserRepository } from '@modules/auth';
|
||||||
import { DomainException, NotFoundException, ValidationException, LoggerService } from '@modules/shared';
|
import { DomainException, NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
|
||||||
import { UserBannedEvent } from '../../../domain/events/user-banned.event';
|
import { UserBannedEvent } from '../../../domain/events/user-banned.event';
|
||||||
import { UserUnbannedEvent } from '../../../domain/events/user-unbanned.event';
|
import { UserUnbannedEvent } from '../../../domain/events/user-unbanned.event';
|
||||||
import { UpdateUserStatusCommand } from './update-user-status.command';
|
import { UpdateUserStatusCommand } from './update-user-status.command';
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import { LoggerService } from '@modules/shared';
|
import { type LoggerService } from '@modules/shared';
|
||||||
import { KycApprovedEvent } from '../../domain/events/kyc-approved.event';
|
import { type KycApprovedEvent } from '../../domain/events/kyc-approved.event';
|
||||||
import { KycRejectedEvent } from '../../domain/events/kyc-rejected.event';
|
import { type KycRejectedEvent } from '../../domain/events/kyc-rejected.event';
|
||||||
import { ListingApprovedEvent } from '../../domain/events/listing-approved.event';
|
import { type ListingApprovedEvent } from '../../domain/events/listing-approved.event';
|
||||||
import { ListingRejectedEvent } from '../../domain/events/listing-rejected.event';
|
import { type ListingRejectedEvent } from '../../domain/events/listing-rejected.event';
|
||||||
import { SubscriptionAdjustedEvent } from '../../domain/events/subscription-adjusted.event';
|
import { type SubscriptionAdjustedEvent } from '../../domain/events/subscription-adjusted.event';
|
||||||
import { UserBannedEvent } from '../../domain/events/user-banned.event';
|
import { type UserBannedEvent } from '../../domain/events/user-banned.event';
|
||||||
import { UserUnbannedEvent } from '../../domain/events/user-unbanned.event';
|
import { type UserUnbannedEvent } from '../../domain/events/user-unbanned.event';
|
||||||
import {
|
import {
|
||||||
AUDIT_LOG_REPOSITORY,
|
AUDIT_LOG_REPOSITORY,
|
||||||
IAuditLogRepository,
|
type IAuditLogRepository,
|
||||||
} from '../../domain/repositories/audit-log.repository';
|
} from '../../domain/repositories/audit-log.repository';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { CommandBus } from '@nestjs/cqrs';
|
import { type CommandBus } from '@nestjs/cqrs';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import { SendNotificationCommand } from '@modules/notifications';
|
import { SendNotificationCommand } from '@modules/notifications';
|
||||||
import { LoggerService, PrismaService } from '@modules/shared';
|
import { type LoggerService, type PrismaService } from '@modules/shared';
|
||||||
import { UserBannedEvent } from '../../domain/events/user-banned.event';
|
import { type UserBannedEvent } from '../../domain/events/user-banned.event';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserBannedListener {
|
export class UserBannedListener {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import { UserDeactivatedEvent } from '@modules/auth';
|
import { type UserDeactivatedEvent } from '@modules/auth';
|
||||||
import { LoggerService, PrismaService } from '@modules/shared';
|
import { type LoggerService, type PrismaService } from '@modules/shared';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserDeactivatedListener {
|
export class UserDeactivatedListener {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { QueryHandler, IQueryHandler } from '@nestjs/cqrs';
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
import { DomainException, LoggerService } from '@modules/shared';
|
import { DomainException, type LoggerService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
AUDIT_LOG_REPOSITORY,
|
AUDIT_LOG_REPOSITORY,
|
||||||
IAuditLogRepository,
|
type IAuditLogRepository,
|
||||||
type AuditLogListResult,
|
type AuditLogListResult,
|
||||||
} from '../../../domain/repositories/audit-log.repository';
|
} from '../../../domain/repositories/audit-log.repository';
|
||||||
import { GetAuditLogsQuery } from './get-audit-logs.query';
|
import { GetAuditLogsQuery } from './get-audit-logs.query';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { QueryHandler, IQueryHandler } from '@nestjs/cqrs';
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
import { DomainException, LoggerService } from '@modules/shared';
|
import { DomainException, type LoggerService } from '@modules/shared';
|
||||||
import { ADMIN_QUERY_REPOSITORY, IAdminQueryRepository, DashboardStats } from '../../../domain/repositories/admin-query.repository';
|
import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type DashboardStats } from '../../../domain/repositories/admin-query.repository';
|
||||||
import { GetDashboardStatsQuery } from './get-dashboard-stats.query';
|
import { GetDashboardStatsQuery } from './get-dashboard-stats.query';
|
||||||
|
|
||||||
@QueryHandler(GetDashboardStatsQuery)
|
@QueryHandler(GetDashboardStatsQuery)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { QueryHandler, IQueryHandler } from '@nestjs/cqrs';
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
import { DomainException, LoggerService } from '@modules/shared';
|
import { DomainException, type LoggerService } from '@modules/shared';
|
||||||
import { ADMIN_QUERY_REPOSITORY, IAdminQueryRepository, KycQueueResult } from '../../../domain/repositories/admin-query.repository';
|
import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type KycQueueResult } from '../../../domain/repositories/admin-query.repository';
|
||||||
import { GetKycQueueQuery } from './get-kyc-queue.query';
|
import { GetKycQueueQuery } from './get-kyc-queue.query';
|
||||||
|
|
||||||
@QueryHandler(GetKycQueueQuery)
|
@QueryHandler(GetKycQueueQuery)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { QueryHandler, IQueryHandler } from '@nestjs/cqrs';
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
import { DomainException, LoggerService } from '@modules/shared';
|
import { DomainException, type LoggerService } from '@modules/shared';
|
||||||
import { ADMIN_QUERY_REPOSITORY, IAdminQueryRepository, ModerationQueueResult } from '../../../domain/repositories/admin-query.repository';
|
import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type ModerationQueueResult } from '../../../domain/repositories/admin-query.repository';
|
||||||
import { GetModerationQueueQuery } from './get-moderation-queue.query';
|
import { GetModerationQueueQuery } from './get-moderation-queue.query';
|
||||||
|
|
||||||
@QueryHandler(GetModerationQueueQuery)
|
@QueryHandler(GetModerationQueueQuery)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { QueryHandler, IQueryHandler } from '@nestjs/cqrs';
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
import { DomainException, LoggerService } from '@modules/shared';
|
import { DomainException, type LoggerService } from '@modules/shared';
|
||||||
import { ADMIN_QUERY_REPOSITORY, IAdminQueryRepository, RevenueStatsItem } from '../../../domain/repositories/admin-query.repository';
|
import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type RevenueStatsItem } from '../../../domain/repositories/admin-query.repository';
|
||||||
import { GetRevenueStatsQuery } from './get-revenue-stats.query';
|
import { GetRevenueStatsQuery } from './get-revenue-stats.query';
|
||||||
|
|
||||||
@QueryHandler(GetRevenueStatsQuery)
|
@QueryHandler(GetRevenueStatsQuery)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { QueryHandler, IQueryHandler } from '@nestjs/cqrs';
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
import { DomainException, NotFoundException, LoggerService } from '@modules/shared';
|
import { DomainException, NotFoundException, type LoggerService } from '@modules/shared';
|
||||||
import { ADMIN_QUERY_REPOSITORY, IAdminQueryRepository, UserDetail } from '../../../domain/repositories/admin-query.repository';
|
import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type UserDetail } from '../../../domain/repositories/admin-query.repository';
|
||||||
import { GetUserDetailQuery } from './get-user-detail.query';
|
import { GetUserDetailQuery } from './get-user-detail.query';
|
||||||
|
|
||||||
@QueryHandler(GetUserDetailQuery)
|
@QueryHandler(GetUserDetailQuery)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { QueryHandler, IQueryHandler } from '@nestjs/cqrs';
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
import { DomainException, LoggerService } from '@modules/shared';
|
import { DomainException, type LoggerService } from '@modules/shared';
|
||||||
import { ADMIN_QUERY_REPOSITORY, IAdminQueryRepository, UserListResult } from '../../../domain/repositories/admin-query.repository';
|
import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type UserListResult } from '../../../domain/repositories/admin-query.repository';
|
||||||
import { GetUsersQuery } from './get-users.query';
|
import { GetUsersQuery } from './get-users.query';
|
||||||
|
|
||||||
@QueryHandler(GetUsersQuery)
|
@QueryHandler(GetUsersQuery)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DomainEvent } from '@modules/shared';
|
import { type DomainEvent } from '@modules/shared';
|
||||||
|
|
||||||
export class KycApprovedEvent implements DomainEvent {
|
export class KycApprovedEvent implements DomainEvent {
|
||||||
readonly eventName = 'kyc.approved';
|
readonly eventName = 'kyc.approved';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DomainEvent } from '@modules/shared';
|
import { type DomainEvent } from '@modules/shared';
|
||||||
|
|
||||||
export class KycRejectedEvent implements DomainEvent {
|
export class KycRejectedEvent implements DomainEvent {
|
||||||
readonly eventName = 'kyc.rejected';
|
readonly eventName = 'kyc.rejected';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DomainEvent } from '@modules/shared';
|
import { type DomainEvent } from '@modules/shared';
|
||||||
|
|
||||||
export class ListingApprovedEvent implements DomainEvent {
|
export class ListingApprovedEvent implements DomainEvent {
|
||||||
readonly eventName = 'listing.approved_by_admin';
|
readonly eventName = 'listing.approved_by_admin';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DomainEvent } from '@modules/shared';
|
import { type DomainEvent } from '@modules/shared';
|
||||||
|
|
||||||
export class ListingRejectedEvent implements DomainEvent {
|
export class ListingRejectedEvent implements DomainEvent {
|
||||||
readonly eventName = 'listing.rejected_by_admin';
|
readonly eventName = 'listing.rejected_by_admin';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DomainEvent } from '@modules/shared';
|
import { type DomainEvent } from '@modules/shared';
|
||||||
|
|
||||||
export class SubscriptionAdjustedEvent implements DomainEvent {
|
export class SubscriptionAdjustedEvent implements DomainEvent {
|
||||||
readonly eventName = 'subscription.adjusted_by_admin';
|
readonly eventName = 'subscription.adjusted_by_admin';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DomainEvent } from '@modules/shared';
|
import { type DomainEvent } from '@modules/shared';
|
||||||
|
|
||||||
export class UserBannedEvent implements DomainEvent {
|
export class UserBannedEvent implements DomainEvent {
|
||||||
readonly eventName = 'user.banned';
|
readonly eventName = 'user.banned';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DomainEvent } from '@modules/shared';
|
import { type DomainEvent } from '@modules/shared';
|
||||||
|
|
||||||
export class UserUnbannedEvent implements DomainEvent {
|
export class UserUnbannedEvent implements DomainEvent {
|
||||||
readonly eventName = 'user.unbanned';
|
readonly eventName = 'user.unbanned';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PrismaService } from '@modules/shared';
|
import { type PrismaService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
type DashboardStats,
|
type DashboardStats,
|
||||||
type RevenueStatsItem,
|
type RevenueStatsItem,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Prisma, UserRole } from '@prisma/client';
|
import { type Prisma, type UserRole } from '@prisma/client';
|
||||||
import { PrismaService } from '@modules/shared';
|
import { type PrismaService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
type UserListResult,
|
type UserListResult,
|
||||||
type UserDetail,
|
type UserDetail,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '@modules/shared';
|
import { type PrismaService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
IAdminQueryRepository,
|
type IAdminQueryRepository,
|
||||||
type ModerationQueueResult,
|
type ModerationQueueResult,
|
||||||
type DashboardStats,
|
type DashboardStats,
|
||||||
type RevenueStatsItem,
|
type RevenueStatsItem,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { AdminAction, AuditTargetType, Prisma } from '@prisma/client';
|
import { type AdminAction, type AuditTargetType, type Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '@modules/shared';
|
import { type PrismaService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
IAuditLogRepository,
|
type IAuditLogRepository,
|
||||||
type AuditLogEntry,
|
type AuditLogEntry,
|
||||||
type AuditLogListResult,
|
type AuditLogListResult,
|
||||||
type CreateAuditLogInput,
|
type CreateAuditLogInput,
|
||||||
|
|||||||
@@ -6,30 +6,30 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||||
import { JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
||||||
import { ApproveKycCommand } from '../../application/commands/approve-kyc/approve-kyc.command';
|
import { ApproveKycCommand } from '../../application/commands/approve-kyc/approve-kyc.command';
|
||||||
import { ApproveKycResult } from '../../application/commands/approve-kyc/approve-kyc.handler';
|
import { type ApproveKycResult } from '../../application/commands/approve-kyc/approve-kyc.handler';
|
||||||
import { ApproveListingCommand } from '../../application/commands/approve-listing/approve-listing.command';
|
import { ApproveListingCommand } from '../../application/commands/approve-listing/approve-listing.command';
|
||||||
import { ApproveListingResult } from '../../application/commands/approve-listing/approve-listing.handler';
|
import { type ApproveListingResult } from '../../application/commands/approve-listing/approve-listing.handler';
|
||||||
import { BulkModerateListingsCommand } from '../../application/commands/bulk-moderate-listings/bulk-moderate-listings.command';
|
import { BulkModerateListingsCommand } from '../../application/commands/bulk-moderate-listings/bulk-moderate-listings.command';
|
||||||
import { BulkModerateResult } from '../../application/commands/bulk-moderate-listings/bulk-moderate-listings.handler';
|
import { type BulkModerateResult } from '../../application/commands/bulk-moderate-listings/bulk-moderate-listings.handler';
|
||||||
import { RejectKycCommand } from '../../application/commands/reject-kyc/reject-kyc.command';
|
import { RejectKycCommand } from '../../application/commands/reject-kyc/reject-kyc.command';
|
||||||
import { RejectKycResult } from '../../application/commands/reject-kyc/reject-kyc.handler';
|
import { type RejectKycResult } from '../../application/commands/reject-kyc/reject-kyc.handler';
|
||||||
import { RejectListingCommand } from '../../application/commands/reject-listing/reject-listing.command';
|
import { RejectListingCommand } from '../../application/commands/reject-listing/reject-listing.command';
|
||||||
import { RejectListingResult } from '../../application/commands/reject-listing/reject-listing.handler';
|
import { type RejectListingResult } from '../../application/commands/reject-listing/reject-listing.handler';
|
||||||
import { GetKycQueueQuery } from '../../application/queries/get-kyc-queue/get-kyc-queue.query';
|
import { GetKycQueueQuery } from '../../application/queries/get-kyc-queue/get-kyc-queue.query';
|
||||||
import { GetModerationQueueQuery } from '../../application/queries/get-moderation-queue/get-moderation-queue.query';
|
import { GetModerationQueueQuery } from '../../application/queries/get-moderation-queue/get-moderation-queue.query';
|
||||||
import {
|
import {
|
||||||
type ModerationQueueResult,
|
type ModerationQueueResult,
|
||||||
type KycQueueResult,
|
type KycQueueResult,
|
||||||
} from '../../domain/repositories/admin-query.repository';
|
} from '../../domain/repositories/admin-query.repository';
|
||||||
import { ApproveKycDto } from '../dto/approve-kyc.dto';
|
import { type ApproveKycDto } from '../dto/approve-kyc.dto';
|
||||||
import { ApproveListingDto } from '../dto/approve-listing.dto';
|
import { type ApproveListingDto } from '../dto/approve-listing.dto';
|
||||||
import { BulkModerateDto } from '../dto/bulk-moderate.dto';
|
import { type BulkModerateDto } from '../dto/bulk-moderate.dto';
|
||||||
import { RejectKycDto } from '../dto/reject-kyc.dto';
|
import { type RejectKycDto } from '../dto/reject-kyc.dto';
|
||||||
import { RejectListingDto } from '../dto/reject-listing.dto';
|
import { type RejectListingDto } from '../dto/reject-listing.dto';
|
||||||
|
|
||||||
@ApiTags('admin')
|
@ApiTags('admin')
|
||||||
@ApiBearerAuth('JWT')
|
@ApiBearerAuth('JWT')
|
||||||
|
|||||||
@@ -8,15 +8,15 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger';
|
||||||
import { JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
||||||
import { AdjustSubscriptionCommand } from '../../application/commands/adjust-subscription/adjust-subscription.command';
|
import { AdjustSubscriptionCommand } from '../../application/commands/adjust-subscription/adjust-subscription.command';
|
||||||
import { AdjustSubscriptionResult } from '../../application/commands/adjust-subscription/adjust-subscription.handler';
|
import { type AdjustSubscriptionResult } from '../../application/commands/adjust-subscription/adjust-subscription.handler';
|
||||||
import { BanUserCommand } from '../../application/commands/ban-user/ban-user.command';
|
import { BanUserCommand } from '../../application/commands/ban-user/ban-user.command';
|
||||||
import { BanUserResult } from '../../application/commands/ban-user/ban-user.handler';
|
import { type BanUserResult } from '../../application/commands/ban-user/ban-user.handler';
|
||||||
import { UpdateUserStatusCommand } from '../../application/commands/update-user-status/update-user-status.command';
|
import { UpdateUserStatusCommand } from '../../application/commands/update-user-status/update-user-status.command';
|
||||||
import { UpdateUserStatusResult } from '../../application/commands/update-user-status/update-user-status.handler';
|
import { type UpdateUserStatusResult } from '../../application/commands/update-user-status/update-user-status.handler';
|
||||||
import { GetAuditLogsQuery } from '../../application/queries/get-audit-logs/get-audit-logs.query';
|
import { GetAuditLogsQuery } from '../../application/queries/get-audit-logs/get-audit-logs.query';
|
||||||
import { GetDashboardStatsQuery } from '../../application/queries/get-dashboard-stats/get-dashboard-stats.query';
|
import { GetDashboardStatsQuery } from '../../application/queries/get-dashboard-stats/get-dashboard-stats.query';
|
||||||
import { GetRevenueStatsQuery } from '../../application/queries/get-revenue-stats/get-revenue-stats.query';
|
import { GetRevenueStatsQuery } from '../../application/queries/get-revenue-stats/get-revenue-stats.query';
|
||||||
@@ -28,13 +28,13 @@ import {
|
|||||||
type UserListResult,
|
type UserListResult,
|
||||||
type UserDetail,
|
type UserDetail,
|
||||||
} from '../../domain/repositories/admin-query.repository';
|
} from '../../domain/repositories/admin-query.repository';
|
||||||
import { AuditLogListResult } from '../../domain/repositories/audit-log.repository';
|
import { type AuditLogListResult } from '../../domain/repositories/audit-log.repository';
|
||||||
import { AdjustSubscriptionDto } from '../dto/adjust-subscription.dto';
|
import { type AdjustSubscriptionDto } from '../dto/adjust-subscription.dto';
|
||||||
import { BanUserDto } from '../dto/ban-user.dto';
|
import { type BanUserDto } from '../dto/ban-user.dto';
|
||||||
import { GetAuditLogsQueryDto } from '../dto/get-audit-logs-query.dto';
|
import { type GetAuditLogsQueryDto } from '../dto/get-audit-logs-query.dto';
|
||||||
import { GetUsersQueryDto } from '../dto/get-users-query.dto';
|
import { type GetUsersQueryDto } from '../dto/get-users-query.dto';
|
||||||
import { RevenueStatsDto } from '../dto/revenue-stats.dto';
|
import { type RevenueStatsDto } from '../dto/revenue-stats.dto';
|
||||||
import { UpdateUserStatusDto } from '../dto/update-user-status.dto';
|
import { type UpdateUserStatusDto } from '../dto/update-user-status.dto';
|
||||||
|
|
||||||
@ApiTags('admin')
|
@ApiTags('admin')
|
||||||
@ApiBearerAuth('JWT')
|
@ApiBearerAuth('JWT')
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { DomainException, LoggerService } from '@modules/shared';
|
import { DomainException, type LoggerService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
AGENT_REPOSITORY,
|
AGENT_REPOSITORY,
|
||||||
IAgentRepository,
|
type IAgentRepository,
|
||||||
} from '../../../domain/repositories/agent.repository';
|
} from '../../../domain/repositories/agent.repository';
|
||||||
import { QualityScoreCalculator } from '../../../domain/services/quality-score.service';
|
import { QualityScoreCalculator } from '../../../domain/services/quality-score.service';
|
||||||
import { QualityScore } from '../../../domain/value-objects/quality-score.vo';
|
import { QualityScore } from '../../../domain/value-objects/quality-score.vo';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { CommandBus } from '@nestjs/cqrs';
|
import { type CommandBus } from '@nestjs/cqrs';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import { LoggerService } from '@modules/shared';
|
import { type LoggerService } from '@modules/shared';
|
||||||
import { RecalculateQualityScoreCommand } from '../commands/recalculate-quality-score/recalculate-quality-score.command';
|
import { RecalculateQualityScoreCommand } from '../commands/recalculate-quality-score/recalculate-quality-score.command';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||||
import { DomainException, NotFoundException, LoggerService } from '@modules/shared';
|
import { DomainException, NotFoundException, type LoggerService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
AGENT_REPOSITORY,
|
AGENT_REPOSITORY,
|
||||||
type AgentDashboardData,
|
type AgentDashboardData,
|
||||||
IAgentRepository,
|
type IAgentRepository,
|
||||||
} from '../../../domain/repositories/agent.repository';
|
} from '../../../domain/repositories/agent.repository';
|
||||||
import { GetAgentDashboardQuery } from './get-agent-dashboard.query';
|
import { GetAgentDashboardQuery } from './get-agent-dashboard.query';
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||||
import { DomainException, LoggerService } from '@modules/shared';
|
import { DomainException, type LoggerService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
AGENT_REPOSITORY,
|
AGENT_REPOSITORY,
|
||||||
type AgentPublicProfileData,
|
type AgentPublicProfileData,
|
||||||
IAgentRepository,
|
type IAgentRepository,
|
||||||
} from '../../../domain/repositories/agent.repository';
|
} from '../../../domain/repositories/agent.repository';
|
||||||
import { GetAgentPublicProfileQuery } from './get-agent-public-profile.query';
|
import { GetAgentPublicProfileQuery } from './get-agent-public-profile.query';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { AggregateRoot } from '@modules/shared';
|
import { AggregateRoot } from '@modules/shared';
|
||||||
import { QualityScoreUpdatedEvent } from '../events/quality-score-updated.event';
|
import { QualityScoreUpdatedEvent } from '../events/quality-score-updated.event';
|
||||||
import { QualityScore } from '../value-objects/quality-score.vo';
|
import { type QualityScore } from '../value-objects/quality-score.vo';
|
||||||
|
|
||||||
export interface AgentProps {
|
export interface AgentProps {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DomainEvent } from '@modules/shared';
|
import { type DomainEvent } from '@modules/shared';
|
||||||
|
|
||||||
export class QualityScoreUpdatedEvent implements DomainEvent {
|
export class QualityScoreUpdatedEvent implements DomainEvent {
|
||||||
readonly eventName = 'agent.quality_score_updated';
|
readonly eventName = 'agent.quality_score_updated';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AgentEntity } from '../entities/agent.entity';
|
import { type AgentEntity } from '../entities/agent.entity';
|
||||||
|
|
||||||
export const AGENT_REPOSITORY = Symbol('AGENT_REPOSITORY');
|
export const AGENT_REPOSITORY = Symbol('AGENT_REPOSITORY');
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PrismaService } from '@modules/shared';
|
import { type PrismaService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
type AgentPublicProfileData,
|
type AgentPublicProfileData,
|
||||||
type AgentPublicListingItem,
|
type AgentPublicListingItem,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '@modules/shared';
|
import { type PrismaService } from '@modules/shared';
|
||||||
import { AgentEntity } from '../../domain/entities/agent.entity';
|
import { AgentEntity } from '../../domain/entities/agent.entity';
|
||||||
import {
|
import {
|
||||||
type AgentDashboardData,
|
type AgentDashboardData,
|
||||||
type AgentPublicProfileData,
|
type AgentPublicProfileData,
|
||||||
IAgentRepository,
|
type IAgentRepository,
|
||||||
type QualityScoreInputData,
|
type QualityScoreInputData,
|
||||||
} from '../../domain/repositories/agent.repository';
|
} from '../../domain/repositories/agent.repository';
|
||||||
import { QualityScore } from '../../domain/value-objects/quality-score.vo';
|
import { QualityScore } from '../../domain/value-objects/quality-score.vo';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Controller, Get, NotFoundException, Param, Post, UseGuards } from '@nestjs/common';
|
import { Controller, Get, NotFoundException, Param, Post, UseGuards } from '@nestjs/common';
|
||||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
import { RecalculateQualityScoreCommand } from '../../application/commands/recalculate-quality-score/recalculate-quality-score.command';
|
import { RecalculateQualityScoreCommand } from '../../application/commands/recalculate-quality-score/recalculate-quality-score.command';
|
||||||
import { GetAgentDashboardQuery } from '../../application/queries/get-agent-dashboard/get-agent-dashboard.query';
|
import { GetAgentDashboardQuery } from '../../application/queries/get-agent-dashboard/get-agent-dashboard.query';
|
||||||
import { GetAgentPublicProfileQuery } from '../../application/queries/get-agent-public-profile/get-agent-public-profile.query';
|
import { GetAgentPublicProfileQuery } from '../../application/queries/get-agent-public-profile/get-agent-public-profile.query';
|
||||||
import { AgentDashboardData, AgentPublicProfileData } from '../../domain/repositories/agent.repository';
|
import { type AgentDashboardData, type AgentPublicProfileData } from '../../domain/repositories/agent.repository';
|
||||||
|
|
||||||
@ApiTags('agents')
|
@ApiTags('agents')
|
||||||
@Controller('agents')
|
@Controller('agents')
|
||||||
|
|||||||
@@ -4,19 +4,25 @@ import { GenerateReportHandler } from './application/commands/generate-report/ge
|
|||||||
import { TrackEventHandler } from './application/commands/track-event/track-event.handler';
|
import { TrackEventHandler } from './application/commands/track-event/track-event.handler';
|
||||||
import { UpdateMarketIndexHandler } from './application/commands/update-market-index/update-market-index.handler';
|
import { UpdateMarketIndexHandler } from './application/commands/update-market-index/update-market-index.handler';
|
||||||
import { ListingCreatedModerationHandler } from './application/event-handlers/listing-created-moderation.handler';
|
import { ListingCreatedModerationHandler } from './application/event-handlers/listing-created-moderation.handler';
|
||||||
|
import { BatchValuationHandler } from './application/queries/batch-valuation/batch-valuation.handler';
|
||||||
import { GetDistrictStatsHandler } from './application/queries/get-district-stats/get-district-stats.handler';
|
import { GetDistrictStatsHandler } from './application/queries/get-district-stats/get-district-stats.handler';
|
||||||
import { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap.handler';
|
import { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap.handler';
|
||||||
import { GetMarketReportHandler } from './application/queries/get-market-report/get-market-report.handler';
|
import { GetMarketReportHandler } from './application/queries/get-market-report/get-market-report.handler';
|
||||||
|
import { GetNeighborhoodScoreHandler } from './application/queries/get-neighborhood-score/get-neighborhood-score.handler';
|
||||||
import { GetPriceTrendHandler } from './application/queries/get-price-trend/get-price-trend.handler';
|
import { GetPriceTrendHandler } from './application/queries/get-price-trend/get-price-trend.handler';
|
||||||
import { GetValuationHandler } from './application/queries/get-valuation/get-valuation.handler';
|
import { GetValuationHandler } from './application/queries/get-valuation/get-valuation.handler';
|
||||||
|
import { ValuationComparisonHandler } from './application/queries/valuation-comparison/valuation-comparison.handler';
|
||||||
|
import { ValuationHistoryHandler } from './application/queries/valuation-history/valuation-history.handler';
|
||||||
import { MARKET_INDEX_REPOSITORY } from './domain/repositories/market-index.repository';
|
import { MARKET_INDEX_REPOSITORY } from './domain/repositories/market-index.repository';
|
||||||
import { VALUATION_REPOSITORY } from './domain/repositories/valuation.repository';
|
import { VALUATION_REPOSITORY } from './domain/repositories/valuation.repository';
|
||||||
import { AVM_SERVICE } from './domain/services/avm-service';
|
import { AVM_SERVICE } from './domain/services/avm-service';
|
||||||
|
import { NEIGHBORHOOD_SCORE_SERVICE } from './domain/services/neighborhood-score.service';
|
||||||
import { PrismaMarketIndexRepository } from './infrastructure/repositories/prisma-market-index.repository';
|
import { PrismaMarketIndexRepository } from './infrastructure/repositories/prisma-market-index.repository';
|
||||||
import { PrismaValuationRepository } from './infrastructure/repositories/prisma-valuation.repository';
|
import { PrismaValuationRepository } from './infrastructure/repositories/prisma-valuation.repository';
|
||||||
import { AI_SERVICE_CLIENT, AiServiceClient } from './infrastructure/services/ai-service.client';
|
import { AI_SERVICE_CLIENT, AiServiceClient } from './infrastructure/services/ai-service.client';
|
||||||
import { HttpAVMService } from './infrastructure/services/http-avm.service';
|
import { HttpAVMService } from './infrastructure/services/http-avm.service';
|
||||||
import { MarketIndexCronService } from './infrastructure/services/market-index-cron.service';
|
import { MarketIndexCronService } from './infrastructure/services/market-index-cron.service';
|
||||||
|
import { NeighborhoodScoreServiceImpl } from './infrastructure/services/neighborhood-score.service';
|
||||||
import { PrismaAVMService } from './infrastructure/services/prisma-avm.service';
|
import { PrismaAVMService } from './infrastructure/services/prisma-avm.service';
|
||||||
import { AnalyticsController } from './presentation/controllers/analytics.controller';
|
import { AnalyticsController } from './presentation/controllers/analytics.controller';
|
||||||
|
|
||||||
@@ -32,6 +38,10 @@ const QueryHandlers = [
|
|||||||
GetPriceTrendHandler,
|
GetPriceTrendHandler,
|
||||||
GetDistrictStatsHandler,
|
GetDistrictStatsHandler,
|
||||||
GetValuationHandler,
|
GetValuationHandler,
|
||||||
|
BatchValuationHandler,
|
||||||
|
ValuationHistoryHandler,
|
||||||
|
ValuationComparisonHandler,
|
||||||
|
GetNeighborhoodScoreHandler,
|
||||||
];
|
];
|
||||||
|
|
||||||
const EventHandlers = [
|
const EventHandlers = [
|
||||||
@@ -53,6 +63,9 @@ const EventHandlers = [
|
|||||||
PrismaAVMService,
|
PrismaAVMService,
|
||||||
{ provide: AVM_SERVICE, useClass: HttpAVMService },
|
{ provide: AVM_SERVICE, useClass: HttpAVMService },
|
||||||
|
|
||||||
|
// Neighborhood scoring
|
||||||
|
{ provide: NEIGHBORHOOD_SCORE_SERVICE, useClass: NeighborhoodScoreServiceImpl },
|
||||||
|
|
||||||
// Cron
|
// Cron
|
||||||
MarketIndexCronService,
|
MarketIndexCronService,
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PropertyType } from '@prisma/client';
|
import { type PropertyType } from '@prisma/client';
|
||||||
|
|
||||||
export class GenerateReportCommand {
|
export class GenerateReportCommand {
|
||||||
constructor(
|
constructor(
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { DomainException, LoggerService } from '@modules/shared';
|
import { DomainException, type LoggerService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
MARKET_INDEX_REPOSITORY,
|
MARKET_INDEX_REPOSITORY,
|
||||||
IMarketIndexRepository,
|
type IMarketIndexRepository,
|
||||||
type MarketReportResult,
|
type MarketReportResult,
|
||||||
} from '../../../domain/repositories/market-index.repository';
|
} from '../../../domain/repositories/market-index.repository';
|
||||||
import { GenerateReportCommand } from './generate-report.command';
|
import { GenerateReportCommand } from './generate-report.command';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { InternalServerErrorException } from '@nestjs/common';
|
import { InternalServerErrorException } from '@nestjs/common';
|
||||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { DomainException, LoggerService } from '@modules/shared';
|
import { DomainException, type LoggerService } from '@modules/shared';
|
||||||
import { TrackEventCommand } from './track-event.command';
|
import { TrackEventCommand } from './track-event.command';
|
||||||
|
|
||||||
export interface TrackEventResult {
|
export interface TrackEventResult {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PropertyType } from '@prisma/client';
|
import { type PropertyType } from '@prisma/client';
|
||||||
|
|
||||||
export class UpdateMarketIndexCommand {
|
export class UpdateMarketIndexCommand {
|
||||||
constructor(
|
constructor(
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { DomainException, CacheService, CachePrefix, LoggerService } from '@modules/shared';
|
import { DomainException, type CacheService, CachePrefix, type LoggerService } from '@modules/shared';
|
||||||
import { MarketIndexEntity } from '../../../domain/entities/market-index.entity';
|
import { MarketIndexEntity } from '../../../domain/entities/market-index.entity';
|
||||||
import {
|
import {
|
||||||
MARKET_INDEX_REPOSITORY,
|
MARKET_INDEX_REPOSITORY,
|
||||||
IMarketIndexRepository,
|
type IMarketIndexRepository,
|
||||||
} from '../../../domain/repositories/market-index.repository';
|
} from '../../../domain/repositories/market-index.repository';
|
||||||
import { UpdateMarketIndexCommand } from './update-market-index.command';
|
import { UpdateMarketIndexCommand } from './update-market-index.command';
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
import { EventsHandler, IEventHandler, CommandBus } from '@nestjs/cqrs';
|
import { EventsHandler, type IEventHandler, type CommandBus } from '@nestjs/cqrs';
|
||||||
import { ListingCreatedEvent, ModerateListingCommand } from '@modules/listings';
|
import { ListingCreatedEvent, ModerateListingCommand } from '@modules/listings';
|
||||||
import { PrismaService, LoggerService } from '@modules/shared';
|
import { type PrismaService, type LoggerService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
AI_SERVICE_CLIENT,
|
AI_SERVICE_CLIENT,
|
||||||
IAiServiceClient,
|
type IAiServiceClient,
|
||||||
} from '../../infrastructure/services/ai-service.client';
|
} from '../../infrastructure/services/ai-service.client';
|
||||||
|
|
||||||
const AUTO_REJECT_THRESHOLD = 0.8;
|
const AUTO_REJECT_THRESHOLD = 0.8;
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
|
import { CacheService, CachePrefix, CacheTTL, DomainException, type LoggerService } from '@modules/shared';
|
||||||
|
import {
|
||||||
|
AVM_SERVICE,
|
||||||
|
type IAVMService,
|
||||||
|
type BatchValuationResult,
|
||||||
|
} from '../../../domain/services/avm-service';
|
||||||
|
import { BatchValuationQuery } from './batch-valuation.query';
|
||||||
|
|
||||||
|
export type BatchValuationDto = BatchValuationResult[];
|
||||||
|
|
||||||
|
@QueryHandler(BatchValuationQuery)
|
||||||
|
export class BatchValuationHandler implements IQueryHandler<BatchValuationQuery> {
|
||||||
|
constructor(
|
||||||
|
@Inject(AVM_SERVICE) private readonly avmService: IAVMService,
|
||||||
|
private readonly cache: CacheService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: BatchValuationQuery): Promise<BatchValuationDto> {
|
||||||
|
try {
|
||||||
|
const cacheKey = CacheService.buildKey(
|
||||||
|
CachePrefix.VALUATION,
|
||||||
|
'batch',
|
||||||
|
...query.propertyIds.slice().sort(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.cache.getOrSet(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
|
const items = query.propertyIds.map((propertyId) => ({ propertyId }));
|
||||||
|
return this.avmService.estimateBatch(items);
|
||||||
|
},
|
||||||
|
CacheTTL.MARKET_DATA,
|
||||||
|
'batch_valuation',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DomainException) throw error;
|
||||||
|
this.logger.error(
|
||||||
|
`Batch valuation failed: ${error instanceof Error ? error.message : error}`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
this.constructor.name,
|
||||||
|
);
|
||||||
|
throw new InternalServerErrorException('Không thể định giá hàng loạt. Vui lòng thử lại sau.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export class BatchValuationQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly propertyIds: string[],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { QueryHandler, IQueryHandler } from '@nestjs/cqrs';
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
import { DomainException, CacheService, CachePrefix, CacheTTL, Cacheable, LoggerService } from '@modules/shared';
|
import { DomainException, type CacheService, CachePrefix, CacheTTL, Cacheable, type LoggerService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
MARKET_INDEX_REPOSITORY,
|
MARKET_INDEX_REPOSITORY,
|
||||||
IMarketIndexRepository,
|
type IMarketIndexRepository,
|
||||||
type DistrictStatsResult,
|
type DistrictStatsResult,
|
||||||
} from '../../../domain/repositories/market-index.repository';
|
} from '../../../domain/repositories/market-index.repository';
|
||||||
import { GetDistrictStatsQuery } from './get-district-stats.query';
|
import { GetDistrictStatsQuery } from './get-district-stats.query';
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { QueryHandler, IQueryHandler } from '@nestjs/cqrs';
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
import { DomainException, CacheService, CachePrefix, CacheTTL, LoggerService } from '@modules/shared';
|
import { DomainException, CacheService, CachePrefix, CacheTTL, type LoggerService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
MARKET_INDEX_REPOSITORY,
|
MARKET_INDEX_REPOSITORY,
|
||||||
IMarketIndexRepository,
|
type IMarketIndexRepository,
|
||||||
type HeatmapDataPoint,
|
type HeatmapDataPoint,
|
||||||
} from '../../../domain/repositories/market-index.repository';
|
} from '../../../domain/repositories/market-index.repository';
|
||||||
import { GetHeatmapQuery } from './get-heatmap.query';
|
import { GetHeatmapQuery } from './get-heatmap.query';
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { QueryHandler, IQueryHandler } from '@nestjs/cqrs';
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
import { DomainException, CacheService, CachePrefix, CacheTTL, LoggerService } from '@modules/shared';
|
import { DomainException, CacheService, CachePrefix, CacheTTL, type LoggerService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
MARKET_INDEX_REPOSITORY,
|
MARKET_INDEX_REPOSITORY,
|
||||||
IMarketIndexRepository,
|
type IMarketIndexRepository,
|
||||||
type MarketReportResult,
|
type MarketReportResult,
|
||||||
} from '../../../domain/repositories/market-index.repository';
|
} from '../../../domain/repositories/market-index.repository';
|
||||||
import { GetMarketReportQuery } from './get-market-report.query';
|
import { GetMarketReportQuery } from './get-market-report.query';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PropertyType } from '@prisma/client';
|
import { type PropertyType } from '@prisma/client';
|
||||||
|
|
||||||
export class GetMarketReportQuery {
|
export class GetMarketReportQuery {
|
||||||
constructor(
|
constructor(
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Inject } from '@nestjs/common';
|
||||||
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
NEIGHBORHOOD_SCORE_SERVICE,
|
||||||
|
type INeighborhoodScoreService,
|
||||||
|
type NeighborhoodScoreResult,
|
||||||
|
} from '../../../domain/services/neighborhood-score.service';
|
||||||
|
import { GetNeighborhoodScoreQuery } from './get-neighborhood-score.query';
|
||||||
|
|
||||||
|
@QueryHandler(GetNeighborhoodScoreQuery)
|
||||||
|
export class GetNeighborhoodScoreHandler implements IQueryHandler<GetNeighborhoodScoreQuery> {
|
||||||
|
constructor(
|
||||||
|
@Inject(NEIGHBORHOOD_SCORE_SERVICE)
|
||||||
|
private readonly scoreService: INeighborhoodScoreService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: GetNeighborhoodScoreQuery): Promise<NeighborhoodScoreResult> {
|
||||||
|
// Return cached score if available, otherwise calculate
|
||||||
|
const existing = await this.scoreService.getScore(query.district, query.city);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
return this.scoreService.calculateAndSave(query.district, query.city);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export class GetNeighborhoodScoreQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly district: string,
|
||||||
|
public readonly city: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { QueryHandler, IQueryHandler } from '@nestjs/cqrs';
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
import { DomainException, CacheService, CachePrefix, CacheTTL, LoggerService } from '@modules/shared';
|
import { DomainException, CacheService, CachePrefix, CacheTTL, type LoggerService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
MARKET_INDEX_REPOSITORY,
|
MARKET_INDEX_REPOSITORY,
|
||||||
IMarketIndexRepository,
|
type IMarketIndexRepository,
|
||||||
type PriceTrendPoint,
|
type PriceTrendPoint,
|
||||||
} from '../../../domain/repositories/market-index.repository';
|
} from '../../../domain/repositories/market-index.repository';
|
||||||
import { GetPriceTrendQuery } from './get-price-trend.query';
|
import { GetPriceTrendQuery } from './get-price-trend.query';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PropertyType } from '@prisma/client';
|
import { type PropertyType } from '@prisma/client';
|
||||||
|
|
||||||
export class GetPriceTrendQuery {
|
export class GetPriceTrendQuery {
|
||||||
constructor(
|
constructor(
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { QueryHandler, IQueryHandler } from '@nestjs/cqrs';
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
import { DomainException, CacheService, CachePrefix, CacheTTL, LoggerService } from '@modules/shared';
|
import { DomainException, CacheService, CachePrefix, CacheTTL, type LoggerService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
AVM_SERVICE,
|
AVM_SERVICE,
|
||||||
IAVMService,
|
type IAVMService,
|
||||||
type ValuationResult,
|
type ValuationResult,
|
||||||
} from '../../../domain/services/avm-service';
|
} from '../../../domain/services/avm-service';
|
||||||
import { GetValuationQuery } from './get-valuation.query';
|
import { GetValuationQuery } from './get-valuation.query';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PropertyType } from '@prisma/client';
|
import { type PropertyType } from '@prisma/client';
|
||||||
|
|
||||||
export class GetValuationQuery {
|
export class GetValuationQuery {
|
||||||
constructor(
|
constructor(
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
|
import { CacheService, CachePrefix, CacheTTL, DomainException, type LoggerService, type PrismaService } from '@modules/shared';
|
||||||
|
import {
|
||||||
|
AVM_SERVICE,
|
||||||
|
type IAVMService,
|
||||||
|
type ValuationComparisonItem,
|
||||||
|
type ValuationResult,
|
||||||
|
} from '../../../domain/services/avm-service';
|
||||||
|
import { generateConfidenceExplanation } from '../../../infrastructure/services/confidence-explanation.helper';
|
||||||
|
import { ValuationComparisonQuery } from './valuation-comparison.query';
|
||||||
|
|
||||||
|
export interface ValuationComparisonDto {
|
||||||
|
properties: ValuationComparisonItem[];
|
||||||
|
summary: {
|
||||||
|
highestValue: { propertyId: string; estimatedPrice: string } | null;
|
||||||
|
lowestValue: { propertyId: string; estimatedPrice: string } | null;
|
||||||
|
averagePricePerM2: number;
|
||||||
|
averageConfidence: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@QueryHandler(ValuationComparisonQuery)
|
||||||
|
export class ValuationComparisonHandler implements IQueryHandler<ValuationComparisonQuery> {
|
||||||
|
constructor(
|
||||||
|
@Inject(AVM_SERVICE) private readonly avmService: IAVMService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly cache: CacheService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: ValuationComparisonQuery): Promise<ValuationComparisonDto> {
|
||||||
|
try {
|
||||||
|
const cacheKey = CacheService.buildKey(
|
||||||
|
CachePrefix.VALUATION,
|
||||||
|
'compare',
|
||||||
|
...query.propertyIds.slice().sort(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.cache.getOrSet(
|
||||||
|
cacheKey,
|
||||||
|
() => this.buildComparison(query.propertyIds),
|
||||||
|
CacheTTL.MARKET_DATA,
|
||||||
|
'valuation_comparison',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DomainException) throw error;
|
||||||
|
this.logger.error(
|
||||||
|
`Valuation comparison failed: ${error instanceof Error ? error.message : error}`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
this.constructor.name,
|
||||||
|
);
|
||||||
|
throw new InternalServerErrorException('Không thể so sánh định giá. Vui lòng thử lại sau.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildComparison(propertyIds: string[]): Promise<ValuationComparisonDto> {
|
||||||
|
// Fetch property details and valuations in parallel
|
||||||
|
const [properties, valuations] = await Promise.all([
|
||||||
|
this.prisma.property.findMany({
|
||||||
|
where: { id: { in: propertyIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
address: true,
|
||||||
|
district: true,
|
||||||
|
areaM2: true,
|
||||||
|
propertyType: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.fetchValuations(propertyIds),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const propertyMap = new Map(properties.map((p) => [p.id, p]));
|
||||||
|
const valuationMap = new Map(valuations.map((v) => [v.propertyId, v.valuation]));
|
||||||
|
|
||||||
|
const items: ValuationComparisonItem[] = propertyIds.map((propertyId) => {
|
||||||
|
const prop = propertyMap.get(propertyId);
|
||||||
|
const valuation = valuationMap.get(propertyId) ?? null;
|
||||||
|
|
||||||
|
// Add confidence explanation if we have a valuation
|
||||||
|
const enrichedValuation = valuation
|
||||||
|
? { ...valuation, confidenceExplanation: generateConfidenceExplanation(valuation.confidence, valuation.comparables.length) }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
propertyId,
|
||||||
|
address: prop?.address ?? '',
|
||||||
|
district: prop?.district ?? '',
|
||||||
|
areaM2: prop?.areaM2 ?? 0,
|
||||||
|
propertyType: prop?.propertyType ?? 'APARTMENT',
|
||||||
|
valuation: enrichedValuation,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate summary
|
||||||
|
const validValuations = items.filter((i) => i.valuation !== null);
|
||||||
|
const prices = validValuations.map((i) => ({
|
||||||
|
propertyId: i.propertyId,
|
||||||
|
price: BigInt(i.valuation!.estimatedPrice),
|
||||||
|
priceStr: i.valuation!.estimatedPrice,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let highestValue: { propertyId: string; estimatedPrice: string } | null = null;
|
||||||
|
let lowestValue: { propertyId: string; estimatedPrice: string } | null = null;
|
||||||
|
|
||||||
|
if (prices.length > 0) {
|
||||||
|
const sorted = prices.sort((a, b) => (a.price > b.price ? 1 : a.price < b.price ? -1 : 0));
|
||||||
|
const highest = sorted[sorted.length - 1]!;
|
||||||
|
const lowest = sorted[0]!;
|
||||||
|
highestValue = { propertyId: highest.propertyId, estimatedPrice: highest.priceStr };
|
||||||
|
lowestValue = { propertyId: lowest.propertyId, estimatedPrice: lowest.priceStr };
|
||||||
|
}
|
||||||
|
|
||||||
|
const averagePricePerM2 = validValuations.length > 0
|
||||||
|
? Math.round(validValuations.reduce((sum, i) => sum + i.valuation!.pricePerM2, 0) / validValuations.length)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const averageConfidence = validValuations.length > 0
|
||||||
|
? Math.round(validValuations.reduce((sum, i) => sum + i.valuation!.confidence, 0) / validValuations.length * 100) / 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
properties: items,
|
||||||
|
summary: { highestValue, lowestValue, averagePricePerM2, averageConfidence },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchValuations(propertyIds: string[]): Promise<{ propertyId: string; valuation: ValuationResult | null }[]> {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
propertyIds.map(async (propertyId) => {
|
||||||
|
const valuation = await this.avmService.estimateValue({ propertyId });
|
||||||
|
return { propertyId, valuation };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return results.map((result, index) => {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
return result.value;
|
||||||
|
}
|
||||||
|
return { propertyId: propertyIds[index]!, valuation: null };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export class ValuationComparisonQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly propertyIds: string[],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
|
import { CacheService, CachePrefix, CacheTTL, DomainException, type LoggerService } from '@modules/shared';
|
||||||
|
import {
|
||||||
|
VALUATION_REPOSITORY,
|
||||||
|
type IValuationRepository,
|
||||||
|
} from '../../../domain/repositories/valuation.repository';
|
||||||
|
import { type ValuationHistoryPoint } from '../../../domain/services/avm-service';
|
||||||
|
import { ValuationHistoryQuery } from './valuation-history.query';
|
||||||
|
|
||||||
|
export interface ValuationHistoryDto {
|
||||||
|
propertyId: string;
|
||||||
|
history: ValuationHistoryPoint[];
|
||||||
|
totalRecords: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@QueryHandler(ValuationHistoryQuery)
|
||||||
|
export class ValuationHistoryHandler implements IQueryHandler<ValuationHistoryQuery> {
|
||||||
|
constructor(
|
||||||
|
@Inject(VALUATION_REPOSITORY) private readonly valuationRepo: IValuationRepository,
|
||||||
|
private readonly cache: CacheService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: ValuationHistoryQuery): Promise<ValuationHistoryDto> {
|
||||||
|
try {
|
||||||
|
const cacheKey = CacheService.buildKey(
|
||||||
|
CachePrefix.VALUATION,
|
||||||
|
'history',
|
||||||
|
query.propertyId,
|
||||||
|
query.limit.toString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.cache.getOrSet(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
|
const entities = await this.valuationRepo.findByPropertyId(query.propertyId);
|
||||||
|
const limited = entities.slice(0, query.limit);
|
||||||
|
|
||||||
|
const history: ValuationHistoryPoint[] = limited.map((entity) => ({
|
||||||
|
estimatedPrice: entity.estimatedPrice.toString(),
|
||||||
|
confidence: entity.confidence,
|
||||||
|
pricePerM2: entity.pricePerM2,
|
||||||
|
modelVersion: entity.modelVersion,
|
||||||
|
valuedAt: entity.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
propertyId: query.propertyId,
|
||||||
|
history,
|
||||||
|
totalRecords: entities.length,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
CacheTTL.DISTRICT_STATS,
|
||||||
|
'valuation_history',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DomainException) throw error;
|
||||||
|
this.logger.error(
|
||||||
|
`Valuation history failed for property ${query.propertyId}: ${error instanceof Error ? error.message : error}`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
this.constructor.name,
|
||||||
|
);
|
||||||
|
throw new InternalServerErrorException('Không thể lấy lịch sử định giá. Vui lòng thử lại sau.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export class ValuationHistoryQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly propertyId: string,
|
||||||
|
public readonly limit: number = 50,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PropertyType } from '@prisma/client';
|
import { type PropertyType } from '@prisma/client';
|
||||||
import { AggregateRoot } from '@modules/shared';
|
import { AggregateRoot } from '@modules/shared';
|
||||||
import { MarketIndexUpdatedEvent } from '../events/market-index-updated.event';
|
import { MarketIndexUpdatedEvent } from '../events/market-index-updated.event';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DomainEvent } from '@modules/shared';
|
import { type DomainEvent } from '@modules/shared';
|
||||||
|
|
||||||
export class MarketIndexUpdatedEvent implements DomainEvent {
|
export class MarketIndexUpdatedEvent implements DomainEvent {
|
||||||
readonly eventName = 'market-index.updated';
|
readonly eventName = 'market-index.updated';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PropertyType } from '@prisma/client';
|
import { type PropertyType } from '@prisma/client';
|
||||||
import { MarketIndexEntity } from '../entities/market-index.entity';
|
import { type MarketIndexEntity } from '../entities/market-index.entity';
|
||||||
|
|
||||||
export const MARKET_INDEX_REPOSITORY = Symbol('MARKET_INDEX_REPOSITORY');
|
export const MARKET_INDEX_REPOSITORY = Symbol('MARKET_INDEX_REPOSITORY');
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ValuationEntity } from '../entities/valuation.entity';
|
import { type ValuationEntity } from '../entities/valuation.entity';
|
||||||
|
|
||||||
export const VALUATION_REPOSITORY = Symbol('VALUATION_REPOSITORY');
|
export const VALUATION_REPOSITORY = Symbol('VALUATION_REPOSITORY');
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PropertyType } from '@prisma/client';
|
import { type PropertyType } from '@prisma/client';
|
||||||
|
|
||||||
export const AVM_SERVICE = Symbol('AVM_SERVICE');
|
export const AVM_SERVICE = Symbol('AVM_SERVICE');
|
||||||
|
|
||||||
@@ -31,9 +31,38 @@ export interface ValuationResult {
|
|||||||
pricePerM2: number;
|
pricePerM2: number;
|
||||||
comparables: Comparable[];
|
comparables: Comparable[];
|
||||||
modelVersion: string;
|
modelVersion: string;
|
||||||
|
confidenceExplanation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchValuationItem {
|
||||||
|
propertyId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchValuationResult {
|
||||||
|
propertyId: string;
|
||||||
|
valuation: ValuationResult | null;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValuationHistoryPoint {
|
||||||
|
estimatedPrice: string;
|
||||||
|
confidence: number;
|
||||||
|
pricePerM2: number;
|
||||||
|
modelVersion: string;
|
||||||
|
valuedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValuationComparisonItem {
|
||||||
|
propertyId: string;
|
||||||
|
address: string;
|
||||||
|
district: string;
|
||||||
|
areaM2: number;
|
||||||
|
propertyType: PropertyType;
|
||||||
|
valuation: ValuationResult | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAVMService {
|
export interface IAVMService {
|
||||||
estimateValue(params: AVMParams): Promise<ValuationResult>;
|
estimateValue(params: AVMParams): Promise<ValuationResult>;
|
||||||
getComparables(propertyId: string, radiusMeters: number): Promise<Comparable[]>;
|
getComparables(propertyId: string, radiusMeters: number): Promise<Comparable[]>;
|
||||||
|
estimateBatch(items: BatchValuationItem[]): Promise<BatchValuationResult[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export const NEIGHBORHOOD_SCORE_SERVICE = Symbol('NEIGHBORHOOD_SCORE_SERVICE');
|
||||||
|
|
||||||
|
export interface NeighborhoodScoreResult {
|
||||||
|
district: string;
|
||||||
|
city: string;
|
||||||
|
educationScore: number;
|
||||||
|
healthcareScore: number;
|
||||||
|
transportScore: number;
|
||||||
|
shoppingScore: number;
|
||||||
|
greeneryScore: number;
|
||||||
|
safetyScore: number;
|
||||||
|
totalScore: number;
|
||||||
|
poiCounts: Record<string, number>;
|
||||||
|
calculatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INeighborhoodScoreService {
|
||||||
|
getScore(district: string, city: string): Promise<NeighborhoodScoreResult | null>;
|
||||||
|
calculateAndSave(district: string, city: string): Promise<NeighborhoodScoreResult>;
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { MarketIndex as PrismaMarketIndex, PropertyType } from '@prisma/client';
|
import { type MarketIndex as PrismaMarketIndex, type PropertyType } from '@prisma/client';
|
||||||
import { PrismaService } from '@modules/shared';
|
import { type PrismaService } from '@modules/shared';
|
||||||
import { MarketIndexEntity, MarketIndexProps } from '../../domain/entities/market-index.entity';
|
import { MarketIndexEntity, type MarketIndexProps } from '../../domain/entities/market-index.entity';
|
||||||
import {
|
import {
|
||||||
IMarketIndexRepository,
|
type IMarketIndexRepository,
|
||||||
type MarketReportResult,
|
type MarketReportResult,
|
||||||
type HeatmapDataPoint,
|
type HeatmapDataPoint,
|
||||||
type PriceTrendPoint,
|
type PriceTrendPoint,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Prisma, Valuation as PrismaValuation } from '@prisma/client';
|
import { type Prisma, type Valuation as PrismaValuation } from '@prisma/client';
|
||||||
import { PrismaService } from '@modules/shared';
|
import { type PrismaService } from '@modules/shared';
|
||||||
import { ValuationEntity, ValuationProps } from '../../domain/entities/valuation.entity';
|
import { ValuationEntity, type ValuationProps } from '../../domain/entities/valuation.entity';
|
||||||
import { IValuationRepository } from '../../domain/repositories/valuation.repository';
|
import { type IValuationRepository } from '../../domain/repositories/valuation.repository';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaValuationRepository implements IValuationRepository {
|
export class PrismaValuationRepository implements IValuationRepository {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { LoggerService } from '@modules/shared';
|
import { type LoggerService } from '@modules/shared';
|
||||||
|
|
||||||
export interface AiPredictRequest {
|
export interface AiPredictRequest {
|
||||||
area: number;
|
area: number;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PropertyType } from '@prisma/client';
|
import { type PropertyType } from '@prisma/client';
|
||||||
import { Comparable } from '../../domain/services/avm-service';
|
import { type Comparable } from '../../domain/services/avm-service';
|
||||||
|
|
||||||
const DEFAULT_RADIUS_METERS = 2000;
|
const DEFAULT_RADIUS_METERS = 2000;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Generates a human-readable Vietnamese explanation of the AVM confidence score.
|
||||||
|
*
|
||||||
|
* The explanation considers:
|
||||||
|
* - Overall confidence level (high/medium/low)
|
||||||
|
* - Number of comparable properties used
|
||||||
|
* - General market data quality
|
||||||
|
*/
|
||||||
|
export function generateConfidenceExplanation(
|
||||||
|
confidence: number,
|
||||||
|
comparableCount: number,
|
||||||
|
): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
// Confidence level description
|
||||||
|
if (confidence >= 0.8) {
|
||||||
|
parts.push('Mức độ tin cậy cao');
|
||||||
|
} else if (confidence >= 0.5) {
|
||||||
|
parts.push('Mức độ tin cậy trung bình');
|
||||||
|
} else if (confidence > 0) {
|
||||||
|
parts.push('Mức độ tin cậy thấp');
|
||||||
|
} else {
|
||||||
|
return 'Không đủ dữ liệu để đưa ra ước tính đáng tin cậy. Kết quả chỉ mang tính tham khảo.';
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(`(${Math.round(confidence * 100)}%).`);
|
||||||
|
|
||||||
|
// Comparable properties context
|
||||||
|
if (comparableCount >= 10) {
|
||||||
|
parts.push(`Dựa trên ${comparableCount} bất động sản tương đương trong khu vực, cung cấp cơ sở dữ liệu vững chắc.`);
|
||||||
|
} else if (comparableCount >= 5) {
|
||||||
|
parts.push(`Dựa trên ${comparableCount} bất động sản tương đương. Dữ liệu đủ để ước tính hợp lý.`);
|
||||||
|
} else if (comparableCount >= 3) {
|
||||||
|
parts.push(`Chỉ có ${comparableCount} bất động sản tương đương. Kết quả có thể dao động.`);
|
||||||
|
} else {
|
||||||
|
parts.push('Số lượng bất động sản tương đương hạn chế. Nên tham khảo thêm các nguồn khác.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional guidance based on confidence
|
||||||
|
if (confidence < 0.5) {
|
||||||
|
parts.push('Khuyến nghị: Nên tham vấn chuyên gia định giá để có kết quả chính xác hơn.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
@@ -1,17 +1,22 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { PrismaService, LoggerService } from '@modules/shared';
|
import { type PrismaService, type LoggerService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
IAVMService,
|
type IAVMService,
|
||||||
type AVMParams,
|
type AVMParams,
|
||||||
type ValuationResult,
|
type ValuationResult,
|
||||||
type Comparable,
|
type Comparable,
|
||||||
|
type BatchValuationItem,
|
||||||
|
type BatchValuationResult,
|
||||||
} from '../../domain/services/avm-service';
|
} from '../../domain/services/avm-service';
|
||||||
import {
|
import {
|
||||||
AI_SERVICE_CLIENT,
|
AI_SERVICE_CLIENT,
|
||||||
IAiServiceClient,
|
type IAiServiceClient,
|
||||||
type AiPredictRequest,
|
type AiPredictRequest,
|
||||||
} from './ai-service.client';
|
} from './ai-service.client';
|
||||||
import { PrismaAVMService } from './prisma-avm.service';
|
import { type PrismaAVMService } from './prisma-avm.service';
|
||||||
|
|
||||||
|
/** Max concurrency for batch AI calls to avoid overloading the Python service. */
|
||||||
|
const BATCH_CONCURRENCY = 5;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class HttpAVMService implements IAVMService {
|
export class HttpAVMService implements IAVMService {
|
||||||
@@ -38,6 +43,41 @@ export class HttpAVMService implements IAVMService {
|
|||||||
return this.fallback.getComparables(propertyId, radiusMeters);
|
return this.fallback.getComparables(propertyId, radiusMeters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async estimateBatch(items: BatchValuationItem[]): Promise<BatchValuationResult[]> {
|
||||||
|
const results: BatchValuationResult[] = [];
|
||||||
|
|
||||||
|
// Process in batches with limited concurrency
|
||||||
|
for (let i = 0; i < items.length; i += BATCH_CONCURRENCY) {
|
||||||
|
const chunk = items.slice(i, i + BATCH_CONCURRENCY);
|
||||||
|
const chunkResults = await Promise.allSettled(
|
||||||
|
chunk.map(async (item) => {
|
||||||
|
const valuation = await this.estimateValue({ propertyId: item.propertyId });
|
||||||
|
return { propertyId: item.propertyId, valuation } as BatchValuationResult;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let j = 0; j < chunkResults.length; j++) {
|
||||||
|
const result = chunkResults[j]!;
|
||||||
|
const item = chunk[j]!;
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
results.push(result.value);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(
|
||||||
|
`Batch valuation failed for property ${item.propertyId}: ${String(result.reason)}`,
|
||||||
|
'HttpAVMService',
|
||||||
|
);
|
||||||
|
results.push({
|
||||||
|
propertyId: item.propertyId,
|
||||||
|
valuation: null,
|
||||||
|
error: result.reason instanceof Error ? result.reason.message : 'Lỗi định giá',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
private async estimateViaAi(params: AVMParams): Promise<ValuationResult> {
|
private async estimateViaAi(params: AVMParams): Promise<ValuationResult> {
|
||||||
const propertyData = params.propertyId
|
const propertyData = params.propertyId
|
||||||
? await this.getPropertyDetails(params.propertyId)
|
? await this.getPropertyDetails(params.propertyId)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { CommandBus } from '@nestjs/cqrs';
|
import { type CommandBus } from '@nestjs/cqrs';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { PropertyType } from '@prisma/client';
|
import { PropertyType } from '@prisma/client';
|
||||||
import { PrismaService, LoggerService } from '@modules/shared';
|
import { type PrismaService, type LoggerService } from '@modules/shared';
|
||||||
import { UpdateMarketIndexCommand } from '../../application/commands/update-market-index/update-market-index.command';
|
import { UpdateMarketIndexCommand } from '../../application/commands/update-market-index/update-market-index.command';
|
||||||
|
|
||||||
interface MarketStats {
|
interface MarketStats {
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { type PrismaService, type LoggerService } from '@modules/shared';
|
||||||
|
import {
|
||||||
|
type INeighborhoodScoreService,
|
||||||
|
type NeighborhoodScoreResult,
|
||||||
|
} from '../../domain/services/neighborhood-score.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scoring weights for each POI category.
|
||||||
|
* Sum = 100 (total score is 0–100 weighted average).
|
||||||
|
*/
|
||||||
|
const CATEGORY_WEIGHTS = {
|
||||||
|
education: 20,
|
||||||
|
healthcare: 20,
|
||||||
|
transport: 20,
|
||||||
|
shopping: 15,
|
||||||
|
greenery: 15,
|
||||||
|
safety: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** POI types grouped by scoring category. */
|
||||||
|
const CATEGORY_POI_TYPES: Record<string, string[]> = {
|
||||||
|
education: ['SCHOOL', 'UNIVERSITY'],
|
||||||
|
healthcare: ['HOSPITAL', 'CLINIC'],
|
||||||
|
transport: ['METRO_STATION', 'BUS_STOP'],
|
||||||
|
shopping: ['MALL', 'MARKET', 'SUPERMARKET'],
|
||||||
|
greenery: ['PARK'],
|
||||||
|
safety: ['POLICE_STATION', 'FIRE_STATION'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Max count per category that yields a 10/10 score. */
|
||||||
|
const MAX_COUNTS: Record<string, number> = {
|
||||||
|
education: 15,
|
||||||
|
healthcare: 8,
|
||||||
|
transport: 12,
|
||||||
|
shopping: 10,
|
||||||
|
greenery: 6,
|
||||||
|
safety: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NeighborhoodScoreServiceImpl implements INeighborhoodScoreService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getScore(district: string, city: string): Promise<NeighborhoodScoreResult | null> {
|
||||||
|
const existing = await this.prisma.neighborhoodScore.findUnique({
|
||||||
|
where: { district_city: { district, city } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
district: existing.district,
|
||||||
|
city: existing.city,
|
||||||
|
educationScore: existing.educationScore,
|
||||||
|
healthcareScore: existing.healthcareScore,
|
||||||
|
transportScore: existing.transportScore,
|
||||||
|
shoppingScore: existing.shoppingScore,
|
||||||
|
greeneryScore: existing.greeneryScore,
|
||||||
|
safetyScore: existing.safetyScore,
|
||||||
|
totalScore: existing.totalScore,
|
||||||
|
poiCounts: existing.poiCounts as Record<string, number>,
|
||||||
|
calculatedAt: existing.calculatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async calculateAndSave(district: string, city: string): Promise<NeighborhoodScoreResult> {
|
||||||
|
// Count POIs per category for this district
|
||||||
|
const poiCounts: Record<string, number> = {};
|
||||||
|
const categoryScores: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const [category, poiTypes] of Object.entries(CATEGORY_POI_TYPES)) {
|
||||||
|
const count = await this.prisma.pOI.count({
|
||||||
|
where: {
|
||||||
|
district,
|
||||||
|
city,
|
||||||
|
type: { in: poiTypes as any },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
poiCounts[category] = count;
|
||||||
|
// Score 0–10: linear scale capped at MAX_COUNTS
|
||||||
|
const maxCount = MAX_COUNTS[category]!;
|
||||||
|
categoryScores[category] = Math.min(10, (count / maxCount) * 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weighted total score (0–100)
|
||||||
|
const totalScore = Object.entries(CATEGORY_WEIGHTS).reduce((sum, [cat, weight]) => {
|
||||||
|
return sum + (categoryScores[cat]! * weight) / 10;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const result = await this.prisma.neighborhoodScore.upsert({
|
||||||
|
where: { district_city: { district, city } },
|
||||||
|
create: {
|
||||||
|
district,
|
||||||
|
city,
|
||||||
|
educationScore: categoryScores['education']!,
|
||||||
|
healthcareScore: categoryScores['healthcare']!,
|
||||||
|
transportScore: categoryScores['transport']!,
|
||||||
|
shoppingScore: categoryScores['shopping']!,
|
||||||
|
greeneryScore: categoryScores['greenery']!,
|
||||||
|
safetyScore: categoryScores['safety']!,
|
||||||
|
totalScore: Math.round(totalScore * 10) / 10,
|
||||||
|
poiCounts,
|
||||||
|
calculatedAt: new Date(),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
educationScore: categoryScores['education']!,
|
||||||
|
healthcareScore: categoryScores['healthcare']!,
|
||||||
|
transportScore: categoryScores['transport']!,
|
||||||
|
shoppingScore: categoryScores['shopping']!,
|
||||||
|
greeneryScore: categoryScores['greenery']!,
|
||||||
|
safetyScore: categoryScores['safety']!,
|
||||||
|
totalScore: Math.round(totalScore * 10) / 10,
|
||||||
|
poiCounts,
|
||||||
|
calculatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Neighborhood score calculated: ${district}, ${city} → total=${result.totalScore}`,
|
||||||
|
'NeighborhoodScoreService',
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
district: result.district,
|
||||||
|
city: result.city,
|
||||||
|
educationScore: result.educationScore,
|
||||||
|
healthcareScore: result.healthcareScore,
|
||||||
|
transportScore: result.transportScore,
|
||||||
|
shoppingScore: result.shoppingScore,
|
||||||
|
greeneryScore: result.greeneryScore,
|
||||||
|
safetyScore: result.safetyScore,
|
||||||
|
totalScore: result.totalScore,
|
||||||
|
poiCounts: result.poiCounts as Record<string, number>,
|
||||||
|
calculatedAt: result.calculatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PropertyType } from '@prisma/client';
|
import { type PropertyType } from '@prisma/client';
|
||||||
import { PrismaService } from '@modules/shared';
|
import { type PrismaService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
IAVMService,
|
type IAVMService,
|
||||||
type AVMParams,
|
type AVMParams,
|
||||||
type ValuationResult,
|
type ValuationResult,
|
||||||
type Comparable,
|
type Comparable,
|
||||||
|
type BatchValuationItem,
|
||||||
|
type BatchValuationResult,
|
||||||
} from '../../domain/services/avm-service';
|
} from '../../domain/services/avm-service';
|
||||||
import {
|
import {
|
||||||
type RawComparable,
|
type RawComparable,
|
||||||
@@ -68,6 +70,19 @@ export class PrismaAVMService implements IAVMService {
|
|||||||
return raws.map(toComparableDto);
|
return raws.map(toComparableDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async estimateBatch(items: BatchValuationItem[]): Promise<BatchValuationResult[]> {
|
||||||
|
return Promise.all(
|
||||||
|
items.map(async (item) => {
|
||||||
|
try {
|
||||||
|
const valuation = await this.estimateValue({ propertyId: item.propertyId });
|
||||||
|
return { propertyId: item.propertyId, valuation };
|
||||||
|
} catch {
|
||||||
|
return { propertyId: item.propertyId, valuation: null, error: 'Lỗi định giá' };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveParams(params: AVMParams): Promise<{
|
private async resolveParams(params: AVMParams): Promise<{
|
||||||
lat: number; lng: number; areaM2: number;
|
lat: number; lng: number; areaM2: number;
|
||||||
propertyType: PropertyType | undefined;
|
propertyType: PropertyType | undefined;
|
||||||
|
|||||||
@@ -1,28 +1,43 @@
|
|||||||
import {
|
import {
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { QueryBus } from '@nestjs/cqrs';
|
import { type QueryBus } from '@nestjs/cqrs';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '@modules/auth';
|
import { JwtAuthGuard } from '@modules/auth';
|
||||||
|
import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared';
|
||||||
import { RequireQuota, QuotaGuard } from '@modules/subscriptions';
|
import { RequireQuota, QuotaGuard } from '@modules/subscriptions';
|
||||||
import { DistrictStatsDto } from '../../application/queries/get-district-stats/get-district-stats.handler';
|
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 { GetDistrictStatsQuery } from '../../application/queries/get-district-stats/get-district-stats.query';
|
||||||
import { HeatmapDto } from '../../application/queries/get-heatmap/get-heatmap.handler';
|
import { type HeatmapDto } from '../../application/queries/get-heatmap/get-heatmap.handler';
|
||||||
import { GetHeatmapQuery } from '../../application/queries/get-heatmap/get-heatmap.query';
|
import { GetHeatmapQuery } from '../../application/queries/get-heatmap/get-heatmap.query';
|
||||||
import { MarketReportDto } from '../../application/queries/get-market-report/get-market-report.handler';
|
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 { GetMarketReportQuery } from '../../application/queries/get-market-report/get-market-report.query';
|
||||||
import { PriceTrendDto } from '../../application/queries/get-price-trend/get-price-trend.handler';
|
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 { GetPriceTrendQuery } from '../../application/queries/get-price-trend/get-price-trend.query';
|
||||||
import { ValuationDto } from '../../application/queries/get-valuation/get-valuation.handler';
|
import { type ValuationDto } from '../../application/queries/get-valuation/get-valuation.handler';
|
||||||
import { GetValuationQuery } from '../../application/queries/get-valuation/get-valuation.query';
|
import { GetValuationQuery } from '../../application/queries/get-valuation/get-valuation.query';
|
||||||
import { GetDistrictStatsDto } from '../dto/get-district-stats.dto';
|
import { type ValuationComparisonDto as ValuationComparisonResultDto } from '../../application/queries/valuation-comparison/valuation-comparison.handler';
|
||||||
import { GetHeatmapDto } from '../dto/get-heatmap.dto';
|
import { ValuationComparisonQuery } from '../../application/queries/valuation-comparison/valuation-comparison.query';
|
||||||
import { GetMarketReportDto } from '../dto/get-market-report.dto';
|
import { type ValuationHistoryDto as ValuationHistoryResultDto } from '../../application/queries/valuation-history/valuation-history.handler';
|
||||||
import { GetPriceTrendDto } from '../dto/get-price-trend.dto';
|
import { ValuationHistoryQuery } from '../../application/queries/valuation-history/valuation-history.query';
|
||||||
import { GetValuationDto } from '../dto/get-valuation.dto';
|
import { type NeighborhoodScoreResult } from '../../domain/services/neighborhood-score.service';
|
||||||
|
import { type BatchValuationDto } from '../dto/batch-valuation.dto';
|
||||||
|
import { type GetDistrictStatsDto } from '../dto/get-district-stats.dto';
|
||||||
|
import { type GetHeatmapDto } from '../dto/get-heatmap.dto';
|
||||||
|
import { type GetMarketReportDto } from '../dto/get-market-report.dto';
|
||||||
|
import { type GetPriceTrendDto } from '../dto/get-price-trend.dto';
|
||||||
|
import { type GetValuationDto } from '../dto/get-valuation.dto';
|
||||||
|
import { type ValuationComparisonDto } from '../dto/valuation-comparison.dto';
|
||||||
|
import { type ValuationHistoryDto } from '../dto/valuation-history.dto';
|
||||||
|
|
||||||
@ApiTags('analytics')
|
@ApiTags('analytics')
|
||||||
@Controller('analytics')
|
@Controller('analytics')
|
||||||
@@ -96,4 +111,66 @@ export class AnalyticsController {
|
|||||||
new GetValuationQuery(dto.propertyId, dto.latitude, dto.longitude, dto.areaM2, dto.propertyType),
|
new GetValuationQuery(dto.propertyId, dto.latitude, dto.longitude, dto.areaM2, dto.propertyType),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('JWT')
|
||||||
|
@EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' })
|
||||||
|
@UseGuards(EndpointRateLimitGuard, JwtAuthGuard, QuotaGuard)
|
||||||
|
@RequireQuota('analytics_queries')
|
||||||
|
@Post('valuation/batch')
|
||||||
|
@ApiOperation({ summary: 'Batch valuation for multiple properties (max 50)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Batch valuation results retrieved' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Invalid parameters' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Quota exceeded' })
|
||||||
|
@ApiResponse({ status: 429, description: 'Rate limit exceeded' })
|
||||||
|
async batchValuation(@Body() dto: BatchValuationDto): Promise<BatchValuationQueryDto> {
|
||||||
|
return this.queryBus.execute(
|
||||||
|
new BatchValuationQuery(dto.propertyIds),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('JWT')
|
||||||
|
@UseGuards(JwtAuthGuard, QuotaGuard)
|
||||||
|
@RequireQuota('analytics_queries')
|
||||||
|
@Get('valuation/history/:propertyId')
|
||||||
|
@ApiOperation({ summary: 'Get valuation history for a property (chart data)' })
|
||||||
|
@ApiParam({ name: 'propertyId', description: 'Property ID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Valuation history retrieved' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Quota exceeded' })
|
||||||
|
async getValuationHistory(
|
||||||
|
@Param('propertyId') propertyId: string,
|
||||||
|
@Query() dto: ValuationHistoryDto,
|
||||||
|
): Promise<ValuationHistoryResultDto> {
|
||||||
|
return this.queryBus.execute(
|
||||||
|
new ValuationHistoryQuery(propertyId, dto.limit ?? 50),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('JWT')
|
||||||
|
@EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' })
|
||||||
|
@UseGuards(EndpointRateLimitGuard, JwtAuthGuard, QuotaGuard)
|
||||||
|
@RequireQuota('analytics_queries')
|
||||||
|
@Post('valuation/compare')
|
||||||
|
@ApiOperation({ summary: 'Compare valuations for 2-5 properties side by side' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Valuation comparison retrieved' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Invalid parameters — provide 2-5 property IDs' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Quota exceeded' })
|
||||||
|
@ApiResponse({ status: 429, description: 'Rate limit exceeded' })
|
||||||
|
async compareValuations(@Body() dto: ValuationComparisonDto): Promise<ValuationComparisonResultDto> {
|
||||||
|
return this.queryBus.execute(
|
||||||
|
new ValuationComparisonQuery(dto.propertyIds),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: 'Get neighborhood score for a district' })
|
||||||
|
@ApiParam({ name: 'district', description: 'District name', example: 'Quận 1' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Neighborhood score retrieved' })
|
||||||
|
@Get('neighborhoods/:district/score')
|
||||||
|
async getNeighborhoodScore(
|
||||||
|
@Param('district') district: string,
|
||||||
|
@Query('city') city: string = 'Hồ Chí Minh',
|
||||||
|
): Promise<NeighborhoodScoreResult> {
|
||||||
|
return this.queryBus.execute(
|
||||||
|
new GetNeighborhoodScoreQuery(district, city),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class BatchValuationDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Array of property IDs to valuate (max 50)',
|
||||||
|
example: ['prop-1', 'prop-2'],
|
||||||
|
type: [String],
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ArrayMaxSize(50)
|
||||||
|
@IsString({ each: true })
|
||||||
|
@Transform(({ value }) => (Array.isArray(value) ? value : [value]))
|
||||||
|
propertyIds!: string[];
|
||||||
|
}
|
||||||
@@ -3,3 +3,6 @@ export { GetHeatmapDto } from './get-heatmap.dto';
|
|||||||
export { GetPriceTrendDto } from './get-price-trend.dto';
|
export { GetPriceTrendDto } from './get-price-trend.dto';
|
||||||
export { GetDistrictStatsDto } from './get-district-stats.dto';
|
export { GetDistrictStatsDto } from './get-district-stats.dto';
|
||||||
export { GetValuationDto } from './get-valuation.dto';
|
export { GetValuationDto } from './get-valuation.dto';
|
||||||
|
export { BatchValuationDto } from './batch-valuation.dto';
|
||||||
|
export { ValuationHistoryDto } from './valuation-history.dto';
|
||||||
|
export { ValuationComparisonDto } from './valuation-comparison.dto';
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class ValuationComparisonDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Array of property IDs to compare (2-5 properties)',
|
||||||
|
example: ['prop-1', 'prop-2', 'prop-3'],
|
||||||
|
type: [String],
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(2)
|
||||||
|
@ArrayMaxSize(5)
|
||||||
|
@IsString({ each: true })
|
||||||
|
@Transform(({ value }) => (Array.isArray(value) ? value : [value]))
|
||||||
|
propertyIds!: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class ValuationHistoryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Maximum number of history records (default: 50, max: 100)', default: 50 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
@Transform(({ value }) => (value != null ? parseInt(value, 10) : 50))
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { UserEntity } from '../../domain/entities/user.entity';
|
||||||
|
import { type IUserRepository } from '../../domain/repositories/user.repository';
|
||||||
|
import { Email } from '../../domain/value-objects/email.vo';
|
||||||
|
import { type HashedPassword } from '../../domain/value-objects/hashed-password.vo';
|
||||||
|
import { Phone } from '../../domain/value-objects/phone.vo';
|
||||||
|
import { UpdateProfileCommand } from '../commands/update-profile/update-profile.command';
|
||||||
|
import { UpdateProfileHandler } from '../commands/update-profile/update-profile.handler';
|
||||||
|
|
||||||
|
function createTestUser(overrides?: Partial<{ email: string; id: string }>): UserEntity {
|
||||||
|
const phone = Phone.create('0912345678').unwrap();
|
||||||
|
const pw = { value: 'hashed' } as HashedPassword;
|
||||||
|
const email = overrides?.email ? Email.create(overrides.email).unwrap() : null;
|
||||||
|
return new UserEntity(overrides?.id ?? 'user-1', {
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
passwordHash: pw,
|
||||||
|
fullName: 'Nguyen Van A',
|
||||||
|
avatarUrl: null,
|
||||||
|
role: 'BUYER',
|
||||||
|
kycStatus: 'NONE',
|
||||||
|
kycData: null,
|
||||||
|
isActive: true,
|
||||||
|
totpSecret: null,
|
||||||
|
totpEnabled: false,
|
||||||
|
totpBackupCodes: [],
|
||||||
|
totpEnabledAt: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('UpdateProfileHandler', () => {
|
||||||
|
let handler: UpdateProfileHandler;
|
||||||
|
let mockUserRepo: { [K in keyof IUserRepository]: ReturnType<typeof vi.fn> };
|
||||||
|
let mockCache: { invalidate: ReturnType<typeof vi.fn> };
|
||||||
|
let mockRedis: { set: ReturnType<typeof vi.fn>; get: ReturnType<typeof vi.fn>; del: ReturnType<typeof vi.fn> };
|
||||||
|
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUserRepo = {
|
||||||
|
findById: vi.fn(),
|
||||||
|
findByPhone: vi.fn(),
|
||||||
|
findByEmail: vi.fn(),
|
||||||
|
save: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
updateMfaSecret: vi.fn(),
|
||||||
|
updateMfaEnabled: vi.fn(),
|
||||||
|
updateMfaDisabled: vi.fn(),
|
||||||
|
updateBackupCodes: vi.fn(),
|
||||||
|
};
|
||||||
|
mockCache = { invalidate: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
mockRedis = {
|
||||||
|
set: vi.fn().mockResolvedValue(undefined),
|
||||||
|
get: vi.fn().mockResolvedValue(null),
|
||||||
|
del: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
mockEventBus = { publish: vi.fn() };
|
||||||
|
|
||||||
|
handler = new UpdateProfileHandler(
|
||||||
|
mockUserRepo as any,
|
||||||
|
mockCache as any,
|
||||||
|
mockRedis as any,
|
||||||
|
mockEventBus as any,
|
||||||
|
{ error: vi.fn() } as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates fullName and invalidates cache', async () => {
|
||||||
|
const user = createTestUser();
|
||||||
|
mockUserRepo.findById.mockResolvedValue(user);
|
||||||
|
mockUserRepo.update.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const command = new UpdateProfileCommand('user-1', 'Tran Van B');
|
||||||
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
|
expect(mockUserRepo.findById).toHaveBeenCalledWith('user-1');
|
||||||
|
expect(mockUserRepo.update).toHaveBeenCalledWith(user);
|
||||||
|
expect(result.fullName).toBe('Tran Van B');
|
||||||
|
expect(result.id).toBe('user-1');
|
||||||
|
expect(mockCache.invalidate).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('user-1'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates avatarUrl', async () => {
|
||||||
|
const user = createTestUser();
|
||||||
|
mockUserRepo.findById.mockResolvedValue(user);
|
||||||
|
mockUserRepo.update.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const command = new UpdateProfileCommand('user-1', undefined, 'https://cdn.example.com/avatar.jpg');
|
||||||
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
|
expect(result.avatarUrl).toBe('https://cdn.example.com/avatar.jpg');
|
||||||
|
expect(mockUserRepo.update).toHaveBeenCalledWith(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defers email change via OTP instead of updating directly', async () => {
|
||||||
|
const user = createTestUser();
|
||||||
|
mockUserRepo.findById.mockResolvedValue(user);
|
||||||
|
mockUserRepo.findByEmail.mockResolvedValue(null);
|
||||||
|
mockUserRepo.update.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const command = new UpdateProfileCommand('user-1', undefined, undefined, 'new@example.com');
|
||||||
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
|
// Email should NOT be updated yet — it is deferred pending OTP
|
||||||
|
expect(result.email).toBeNull();
|
||||||
|
expect(result.emailChangePending).toBe(true);
|
||||||
|
|
||||||
|
// OTP stored in Redis
|
||||||
|
expect(mockRedis.set).toHaveBeenCalledWith(
|
||||||
|
'auth:email_change_otp:user-1',
|
||||||
|
expect.stringContaining('new@example.com'),
|
||||||
|
600,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Event emitted for notification
|
||||||
|
expect(mockEventBus.publish).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
eventName: 'user.email_change_requested',
|
||||||
|
newEmail: 'new@example.com',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ConflictException when new email is already taken', async () => {
|
||||||
|
const user = createTestUser();
|
||||||
|
const otherUser = createTestUser({ id: 'user-2', email: 'taken@example.com' });
|
||||||
|
mockUserRepo.findById.mockResolvedValue(user);
|
||||||
|
mockUserRepo.findByEmail.mockResolvedValue(otherUser);
|
||||||
|
|
||||||
|
const command = new UpdateProfileCommand('user-1', undefined, undefined, 'taken@example.com');
|
||||||
|
await expect(handler.execute(command)).rejects.toThrow('Email đã được sử dụng');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips OTP when email is unchanged', async () => {
|
||||||
|
const user = createTestUser({ email: 'same@example.com' });
|
||||||
|
mockUserRepo.findById.mockResolvedValue(user);
|
||||||
|
mockUserRepo.update.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const command = new UpdateProfileCommand('user-1', undefined, undefined, 'same@example.com');
|
||||||
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
|
expect(mockRedis.set).not.toHaveBeenCalled();
|
||||||
|
expect(mockEventBus.publish).not.toHaveBeenCalled();
|
||||||
|
expect(result.emailChangePending).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundException when user does not exist', async () => {
|
||||||
|
mockUserRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const command = new UpdateProfileCommand('non-existent', 'New Name');
|
||||||
|
await expect(handler.execute(command)).rejects.toThrow('Người dùng');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call update or invalidate when user is not found', async () => {
|
||||||
|
mockUserRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const command = new UpdateProfileCommand('non-existent', 'New Name');
|
||||||
|
await expect(handler.execute(command)).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(mockUserRepo.update).not.toHaveBeenCalled();
|
||||||
|
expect(mockCache.invalidate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ValidationException for invalid email format', async () => {
|
||||||
|
const user = createTestUser();
|
||||||
|
mockUserRepo.findById.mockResolvedValue(user);
|
||||||
|
|
||||||
|
const command = new UpdateProfileCommand('user-1', undefined, undefined, 'not-an-email');
|
||||||
|
await expect(handler.execute(command)).rejects.toThrow('Email không hợp lệ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates fullName and avatarUrl while deferring email', async () => {
|
||||||
|
const user = createTestUser();
|
||||||
|
mockUserRepo.findById.mockResolvedValue(user);
|
||||||
|
mockUserRepo.findByEmail.mockResolvedValue(null);
|
||||||
|
mockUserRepo.update.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const command = new UpdateProfileCommand(
|
||||||
|
'user-1',
|
||||||
|
'Le Thi C',
|
||||||
|
'https://cdn.example.com/new.jpg',
|
||||||
|
'new@example.com',
|
||||||
|
);
|
||||||
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
|
expect(result.fullName).toBe('Le Thi C');
|
||||||
|
expect(result.avatarUrl).toBe('https://cdn.example.com/new.jpg');
|
||||||
|
// Email deferred
|
||||||
|
expect(result.email).toBeNull();
|
||||||
|
expect(result.emailChangePending).toBe(true);
|
||||||
|
expect(mockUserRepo.update).toHaveBeenCalledWith(user);
|
||||||
|
expect(mockCache.invalidate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { UserEntity } from '../../domain/entities/user.entity';
|
||||||
|
import { type IUserRepository } from '../../domain/repositories/user.repository';
|
||||||
|
import { Email } from '../../domain/value-objects/email.vo';
|
||||||
|
import { type HashedPassword } from '../../domain/value-objects/hashed-password.vo';
|
||||||
|
import { Phone } from '../../domain/value-objects/phone.vo';
|
||||||
|
import { VerifyEmailChangeCommand } from '../commands/verify-email-change/verify-email-change.command';
|
||||||
|
import { VerifyEmailChangeHandler } from '../commands/verify-email-change/verify-email-change.handler';
|
||||||
|
|
||||||
|
function createTestUser(overrides?: Partial<{ email: string; id: string }>): UserEntity {
|
||||||
|
const phone = Phone.create('0912345678').unwrap();
|
||||||
|
const pw = { value: 'hashed' } as HashedPassword;
|
||||||
|
const email = overrides?.email ? Email.create(overrides.email).unwrap() : null;
|
||||||
|
return new UserEntity(overrides?.id ?? 'user-1', {
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
passwordHash: pw,
|
||||||
|
fullName: 'Nguyen Van A',
|
||||||
|
avatarUrl: null,
|
||||||
|
role: 'BUYER',
|
||||||
|
kycStatus: 'NONE',
|
||||||
|
kycData: null,
|
||||||
|
isActive: true,
|
||||||
|
totpSecret: null,
|
||||||
|
totpEnabled: false,
|
||||||
|
totpBackupCodes: [],
|
||||||
|
totpEnabledAt: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('VerifyEmailChangeHandler', () => {
|
||||||
|
let handler: VerifyEmailChangeHandler;
|
||||||
|
let mockUserRepo: { [K in keyof IUserRepository]: ReturnType<typeof vi.fn> };
|
||||||
|
let mockRedis: { get: ReturnType<typeof vi.fn>; del: ReturnType<typeof vi.fn>; set: ReturnType<typeof vi.fn> };
|
||||||
|
let mockCache: { invalidate: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUserRepo = {
|
||||||
|
findById: vi.fn(),
|
||||||
|
findByPhone: vi.fn(),
|
||||||
|
findByEmail: vi.fn(),
|
||||||
|
save: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
updateMfaSecret: vi.fn(),
|
||||||
|
updateMfaEnabled: vi.fn(),
|
||||||
|
updateMfaDisabled: vi.fn(),
|
||||||
|
updateBackupCodes: vi.fn(),
|
||||||
|
};
|
||||||
|
mockRedis = {
|
||||||
|
get: vi.fn(),
|
||||||
|
del: vi.fn().mockResolvedValue(undefined),
|
||||||
|
set: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
mockCache = { invalidate: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
|
||||||
|
handler = new VerifyEmailChangeHandler(
|
||||||
|
mockUserRepo as any,
|
||||||
|
mockRedis as any,
|
||||||
|
mockCache as any,
|
||||||
|
{ error: vi.fn() } as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies OTP and updates email', async () => {
|
||||||
|
const user = createTestUser();
|
||||||
|
const payload = JSON.stringify({ newEmail: 'new@example.com', code: '123456' });
|
||||||
|
mockRedis.get.mockResolvedValue(payload);
|
||||||
|
mockUserRepo.findById.mockResolvedValue(user);
|
||||||
|
mockUserRepo.findByEmail.mockResolvedValue(null);
|
||||||
|
mockUserRepo.update.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const command = new VerifyEmailChangeCommand('user-1', '123456');
|
||||||
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
|
expect(result.email).toBe('new@example.com');
|
||||||
|
expect(result.id).toBe('user-1');
|
||||||
|
expect(mockRedis.del).toHaveBeenCalledWith('auth:email_change_otp:user-1');
|
||||||
|
expect(mockUserRepo.update).toHaveBeenCalledWith(user);
|
||||||
|
expect(mockCache.invalidate).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('user-1'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ValidationException when OTP has expired', async () => {
|
||||||
|
mockRedis.get.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const command = new VerifyEmailChangeCommand('user-1', '123456');
|
||||||
|
await expect(handler.execute(command)).rejects.toThrow('hết hạn');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ValidationException when OTP code is wrong', async () => {
|
||||||
|
const payload = JSON.stringify({ newEmail: 'new@example.com', code: '123456' });
|
||||||
|
mockRedis.get.mockResolvedValue(payload);
|
||||||
|
|
||||||
|
const command = new VerifyEmailChangeCommand('user-1', '999999');
|
||||||
|
await expect(handler.execute(command)).rejects.toThrow('không đúng');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ConflictException when email was taken since OTP was issued', async () => {
|
||||||
|
const user = createTestUser();
|
||||||
|
const otherUser = createTestUser({ id: 'user-2', email: 'new@example.com' });
|
||||||
|
const payload = JSON.stringify({ newEmail: 'new@example.com', code: '123456' });
|
||||||
|
mockRedis.get.mockResolvedValue(payload);
|
||||||
|
mockUserRepo.findById.mockResolvedValue(user);
|
||||||
|
mockUserRepo.findByEmail.mockResolvedValue(otherUser);
|
||||||
|
|
||||||
|
const command = new VerifyEmailChangeCommand('user-1', '123456');
|
||||||
|
await expect(handler.execute(command)).rejects.toThrow('Email đã được sử dụng');
|
||||||
|
|
||||||
|
// OTP should be cleaned up on conflict
|
||||||
|
expect(mockRedis.del).toHaveBeenCalledWith('auth:email_change_otp:user-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundException when user does not exist', async () => {
|
||||||
|
const payload = JSON.stringify({ newEmail: 'new@example.com', code: '123456' });
|
||||||
|
mockRedis.get.mockResolvedValue(payload);
|
||||||
|
mockUserRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const command = new VerifyEmailChangeCommand('user-1', '123456');
|
||||||
|
await expect(handler.execute(command)).rejects.toThrow('Người dùng');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { InternalServerErrorException } from '@nestjs/common';
|
import { InternalServerErrorException } from '@nestjs/common';
|
||||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { LoggerService, PrismaService, DomainException, NotFoundException, ValidationException } from '@modules/shared';
|
import { type LoggerService, type PrismaService, DomainException, NotFoundException, ValidationException } from '@modules/shared';
|
||||||
import { CancelUserDeletionCommand } from './cancel-user-deletion.command';
|
import { CancelUserDeletionCommand } from './cancel-user-deletion.command';
|
||||||
|
|
||||||
@CommandHandler(CancelUserDeletionCommand)
|
@CommandHandler(CancelUserDeletionCommand)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { DomainException, LoggerService, UnauthorizedException, ValidationException } from '@modules/shared';
|
import { DomainException, type LoggerService, UnauthorizedException, ValidationException } from '@modules/shared';
|
||||||
import { USER_REPOSITORY, IUserRepository } from '../../../domain/repositories/user.repository';
|
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
|
||||||
import { MfaService } from '../../../infrastructure/services/mfa.service';
|
import { type MfaService } from '../../../infrastructure/services/mfa.service';
|
||||||
import { DisableMfaCommand } from './disable-mfa.command';
|
import { DisableMfaCommand } from './disable-mfa.command';
|
||||||
|
|
||||||
@CommandHandler(DisableMfaCommand)
|
@CommandHandler(DisableMfaCommand)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { InternalServerErrorException } from '@nestjs/common';
|
import { InternalServerErrorException } from '@nestjs/common';
|
||||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { LoggerService, PrismaService, DomainException, NotFoundException } from '@modules/shared';
|
import { type LoggerService, type PrismaService, DomainException, NotFoundException } from '@modules/shared';
|
||||||
import { ExportUserDataCommand } from './export-user-data.command';
|
import { ExportUserDataCommand } from './export-user-data.command';
|
||||||
|
|
||||||
export interface UserDataExport {
|
export interface UserDataExport {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user