Compare commits
7 Commits
81ae59cb9d
...
8706fff92f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8706fff92f | ||
|
|
23af73496d | ||
|
|
7e2ccdfb7c | ||
|
|
e798468e4c | ||
|
|
c478abae38 | ||
|
|
ee6d6d4c17 | ||
|
|
65bd641e1f |
26
.env.example
26
.env.example
@@ -111,23 +111,47 @@ NEXT_PUBLIC_MAPBOX_TOKEN=
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Payment Gateways (VNPay, MoMo, ZaloPay)
|
# Payment Gateways (VNPay, MoMo, ZaloPay)
|
||||||
# Leave empty if not using payment features
|
# Leave empty if not using payment features.
|
||||||
|
#
|
||||||
|
# IMPORTANT: The values below default to SANDBOX endpoints. When deploying
|
||||||
|
# with NODE_ENV=production, swap each *_BASE_URL / *_ENDPOINT to the
|
||||||
|
# production URL and set *_TMN_CODE / *_PARTNER_CODE / *_APP_ID / secret
|
||||||
|
# values to live merchant credentials issued by the gateway. See
|
||||||
|
# docs/payment-go-live-checklist.md for the full cutover procedure.
|
||||||
|
# The API logs a startup warning if production mode is detected with
|
||||||
|
# sandbox-looking credentials.
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# VNPay — sandbox by default
|
||||||
|
# Production: VNPAY_BASE_URL=https://pay.vnpay.vn/vpcpay.html
|
||||||
|
# Production: VNPAY_API_URL=https://merchant.vnpay.vn/merchant_webapi/api/transaction
|
||||||
VNPAY_TMN_CODE=
|
VNPAY_TMN_CODE=
|
||||||
VNPAY_HASH_SECRET=
|
VNPAY_HASH_SECRET=
|
||||||
VNPAY_BASE_URL=https://sandbox.vnpayment.vn/paymentv2/vpcpay.html
|
VNPAY_BASE_URL=https://sandbox.vnpayment.vn/paymentv2/vpcpay.html
|
||||||
VNPAY_API_URL=https://sandbox.vnpayment.vn/merchant_webapi/api/transaction
|
VNPAY_API_URL=https://sandbox.vnpayment.vn/merchant_webapi/api/transaction
|
||||||
|
|
||||||
|
# MoMo — sandbox by default
|
||||||
|
# Production: MOMO_ENDPOINT=https://payment.momo.vn/v2/gateway/api
|
||||||
MOMO_PARTNER_CODE=
|
MOMO_PARTNER_CODE=
|
||||||
MOMO_ACCESS_KEY=
|
MOMO_ACCESS_KEY=
|
||||||
MOMO_SECRET_KEY=
|
MOMO_SECRET_KEY=
|
||||||
MOMO_ENDPOINT=https://test-payment.momo.vn/v2/gateway/api
|
MOMO_ENDPOINT=https://test-payment.momo.vn/v2/gateway/api
|
||||||
|
|
||||||
|
# ZaloPay — sandbox by default
|
||||||
|
# Production: ZALOPAY_ENDPOINT=https://openapi.zalopay.vn/v2
|
||||||
ZALOPAY_APP_ID=
|
ZALOPAY_APP_ID=
|
||||||
ZALOPAY_KEY1=
|
ZALOPAY_KEY1=
|
||||||
ZALOPAY_KEY2=
|
ZALOPAY_KEY2=
|
||||||
ZALOPAY_ENDPOINT=https://sb-openapi.zalopay.vn/v2
|
ZALOPAY_ENDPOINT=https://sb-openapi.zalopay.vn/v2
|
||||||
|
|
||||||
|
# Backend base URL used to construct IPN (server-to-server) callback URLs for
|
||||||
|
# MoMo (ipnUrl) and ZaloPay (callback_url). Must point to the API server, NOT
|
||||||
|
# the frontend. Example: https://api.goodgo.vn
|
||||||
|
# Individual gateway callback paths are appended automatically:
|
||||||
|
# MoMo → {PAYMENT_CALLBACK_BASE_URL}/api/v1/payments/callback/momo
|
||||||
|
# ZaloPay → {PAYMENT_CALLBACK_BASE_URL}/api/v1/payments/callback/zalopay
|
||||||
|
PAYMENT_CALLBACK_BASE_URL=https://api.goodgo.vn
|
||||||
|
|
||||||
BANK_TRANSFER_ACCOUNT_NUMBER=
|
BANK_TRANSFER_ACCOUNT_NUMBER=
|
||||||
BANK_TRANSFER_BANK_NAME=
|
BANK_TRANSFER_BANK_NAME=
|
||||||
BANK_TRANSFER_ACCOUNT_HOLDER=
|
BANK_TRANSFER_ACCOUNT_HOLDER=
|
||||||
|
|||||||
37
CHANGELOG.md
37
CHANGELOG.md
@@ -7,6 +7,43 @@ và dự án này tuân theo [Semantic Versioning](https://semver.org/spec/v2.0.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### GOO-33 Documentation Sprint (2026-04-22)
|
||||||
|
|
||||||
|
#### Đã hoàn thành
|
||||||
|
- QA_TRACKER.md — cập nhật test status baseline + Sprint 1-2 test plans
|
||||||
|
- CHANGELOG.md — cập nhật changelog lần cuối (2026-04-22)
|
||||||
|
- PROJECT_TRACKER.md — cập nhật GOO-33 status → in_progress
|
||||||
|
- CONTRIBUTING.md — thêm branching strategy, PR conventions, commit message format
|
||||||
|
- docs/ci-cd.md — tài liệu đầy đủ GitHub Actions pipeline (lint → typecheck → test → build)
|
||||||
|
- docs/onboarding.md — hướng dẫn setup dành cho developer mới
|
||||||
|
- docs/mcp-servers.md — tài liệu 3 MCP servers (search, analytics, valuation)
|
||||||
|
- docs/audits/ — curate từ ~103 → 12 canonical audit reports + archive old files
|
||||||
|
|
||||||
|
### GOO-2 Lead Orchestrator Audit (2026-04-22)
|
||||||
|
|
||||||
|
#### Audit & Planning
|
||||||
|
- Kiểm tra toàn diện codebase: 51 findings (4 blockers, 24 high, 13 medium, 10 low)
|
||||||
|
- Nghiên cứu thị trường BĐS VN: 23 findings (3 P0, 10 P1, 8 P2, 1 P3)
|
||||||
|
- Ma trận đề xuất: 25 cải thiện (Nhóm A) + 20 tính năng mới (Nhóm B) + 10 docs gaps (Nhóm C)
|
||||||
|
- Tạo 32 subtasks (GOO-3 → GOO-34) phân theo 6 sprints
|
||||||
|
- Tạo QA_TRACKER.md, cập nhật PROJECT_TRACKER.md
|
||||||
|
|
||||||
|
#### Đã sửa
|
||||||
|
- GOO-3: Fix double CSRF middleware — login/register/payment callbacks hoạt động (Sprint 1) ✅
|
||||||
|
|
||||||
|
#### Đang triển khai (Sprint 1 Blockers)
|
||||||
|
- GOO-4: UsageRecord atomic metering (fix quota bypass)
|
||||||
|
- GOO-5: Rate-limit POST /auth/exchange-token
|
||||||
|
- GOO-6: Fix MoMo IPN URL (tách ipnUrl khỏi redirectUrl)
|
||||||
|
- GOO-7: JWT validate user status (isActive + deletedAt)
|
||||||
|
|
||||||
|
#### Phát hiện chính (P0 — Launch Blockers)
|
||||||
|
- Thiếu Phone-OTP login (phương thức auth chính ở VN)
|
||||||
|
- legalStatus là free-text, không phải enum (tín hiệu tin cậy #1)
|
||||||
|
- Typesense không hỗ trợ tìm kiếm dấu tiếng Việt
|
||||||
|
- Thiếu phòng trọ (ROOM_RENTAL) trong PropertyType enum
|
||||||
|
- Quận 2/9 đã bị xóa (→ Thủ Đức) nhưng vẫn hardcoded trong UI
|
||||||
|
|
||||||
### Đã thêm (CEO Audit Wave 13 — 2026-04-12)
|
### Đã thêm (CEO Audit Wave 13 — 2026-04-12)
|
||||||
- Quy trình kiểm tra CEO (TEC-1915) — kiểm tra toàn bộ codebase + xem xét trạng thái dự án
|
- Quy trình kiểm tra CEO (TEC-1915) — kiểm tra toàn bộ codebase + xem xét trạng thái dự án
|
||||||
- Tài liệu kế hoạch với báo cáo 7 phần: tóm tắt kiểm tra, các vấn đề quan trọng, ưu tiên, khuyến nghị
|
- Tài liệu kế hoạch với báo cáo 7 phần: tóm tắt kiểm tra, các vấn đề quan trọng, ưu tiên, khuyến nghị
|
||||||
|
|||||||
285
CONTRIBUTING.md
285
CONTRIBUTING.md
@@ -1,5 +1,209 @@
|
|||||||
# Hướng Dẫn Đóng Góp
|
# Hướng Dẫn Đóng Góp
|
||||||
|
|
||||||
|
## Quy Trình Git & Branching
|
||||||
|
|
||||||
|
### Nhánh Chính
|
||||||
|
|
||||||
|
| Nhánh | Mục đích | Protected |
|
||||||
|
|-------|---------|-----------|
|
||||||
|
| `main` / `master` | Production branch — stable releases | ✅ Yes |
|
||||||
|
| `develop` | Development branch — integration point | ✅ Yes |
|
||||||
|
| `feature/*` | Feature branches — phát triển tính năng mới | ❌ No |
|
||||||
|
| `fix/*` | Bug fix branches | ❌ No |
|
||||||
|
| `docs/*` | Documentation updates | ❌ No |
|
||||||
|
| `refactor/*` | Code refactoring, cleanup | ❌ No |
|
||||||
|
|
||||||
|
### Quy Trình Tạo Feature Branch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Cập nhật branch chính
|
||||||
|
git checkout develop
|
||||||
|
git pull origin develop
|
||||||
|
|
||||||
|
# 2. Tạo feature branch
|
||||||
|
git checkout -b feature/awesome-feature
|
||||||
|
|
||||||
|
# Naming convention:
|
||||||
|
# feature/user-authentication
|
||||||
|
# fix/csrf-middleware-double-middleware
|
||||||
|
# docs/api-documentation
|
||||||
|
# refactor/remove-dead-code
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pull Request Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Commit changes
|
||||||
|
git add .
|
||||||
|
git commit -m "feat(auth): implement phone OTP login"
|
||||||
|
|
||||||
|
# 2. Push to origin
|
||||||
|
git push origin feature/awesome-feature
|
||||||
|
|
||||||
|
# 3. Open PR on GitHub
|
||||||
|
# - Title: Short summary (max 70 chars)
|
||||||
|
# - Description: Why, what changed, how to test
|
||||||
|
# - Link related issues: Fixes #GOO-7
|
||||||
|
# - Request reviewers: team lead, domain expert
|
||||||
|
|
||||||
|
# 4. Address review feedback
|
||||||
|
git add .
|
||||||
|
git commit -m "refactor(auth): address PR feedback"
|
||||||
|
git push
|
||||||
|
|
||||||
|
# 5. Merge when approved
|
||||||
|
# - Squash commits if many small fixes
|
||||||
|
# - Delete branch after merge
|
||||||
|
```
|
||||||
|
|
||||||
|
### Protected Branch Rules
|
||||||
|
|
||||||
|
`main` và `develop` branches yêu cầu:
|
||||||
|
|
||||||
|
- ✅ All CI checks pass (lint, typecheck, test, build)
|
||||||
|
- ✅ 1 approval từ code owner
|
||||||
|
- ✅ Dismiss stale PR approvals
|
||||||
|
- ✅ Branches must be up to date before merge
|
||||||
|
- ❌ Force push không được phép
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quy Ước Commit
|
||||||
|
|
||||||
|
Theo chuẩn **[Conventional Commits](https://www.conventionalcommits.org/)**:
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <subject>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Loại Commit (Type)
|
||||||
|
|
||||||
|
| Type | Mục đích | Ví dụ |
|
||||||
|
|------|---------|-------|
|
||||||
|
| **feat** | Tính năng mới | `feat(auth): add phone OTP login` |
|
||||||
|
| **fix** | Bug fix | `fix(csrf): remove double middleware` |
|
||||||
|
| **docs** | Tài liệu | `docs(readme): update setup instructions` |
|
||||||
|
| **style** | Code style (không thay đổi logic) | `style(payment): format code` |
|
||||||
|
| **refactor** | Refactor code | `refactor(search): extract filter logic` |
|
||||||
|
| **perf** | Performance improvement | `perf(search): add Typesense caching` |
|
||||||
|
| **test** | Test changes | `test(auth): add OTP verification tests` |
|
||||||
|
| **chore** | Dependencies, build, etc | `chore(deps): upgrade TypeScript 5.2` |
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
Scope là module/area bị ảnh hưởng:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(auth): ... # Auth module
|
||||||
|
feat(payments): ... # Payments module
|
||||||
|
feat(api): ... # General API
|
||||||
|
feat(web): ... # Frontend
|
||||||
|
feat(deps): ... # Dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
### Subject (Tiêu đề)
|
||||||
|
|
||||||
|
- ✅ Bắt đầu bằng **verb** (not past tense): "add", "fix", "remove"
|
||||||
|
- ✅ Viết **lowercase** (trừ proper nouns)
|
||||||
|
- ✅ **Không kết thúc** bằng dấu chấm
|
||||||
|
- ✅ Tối đa **50 ký tự**
|
||||||
|
- ❌ Không dùng "update", "change" — cụ thể hơn
|
||||||
|
|
||||||
|
### Body (Chi tiết)
|
||||||
|
|
||||||
|
Tùy chọn, giải thích **why** và **how**:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(payments): implement MoMo IPN webhook
|
||||||
|
|
||||||
|
Fix MoMo IPN callback to use correct backend URL instead of frontend URL.
|
||||||
|
|
||||||
|
- Extract ipnUrl from redirectUrl in MoMo service
|
||||||
|
- Validate HMAC signature before processing payment
|
||||||
|
- Add retry logic for idempotent callbacks
|
||||||
|
|
||||||
|
Fixes #GOO-6
|
||||||
|
```
|
||||||
|
|
||||||
|
### Footer (Tham chiếu)
|
||||||
|
|
||||||
|
Tham chiếu issue:
|
||||||
|
|
||||||
|
```
|
||||||
|
Fixes #GOO-7
|
||||||
|
Closes #GOO-8
|
||||||
|
Related to #GOO-5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ví dụ Hoàn Chỉnh
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(auth): implement phone OTP login flow
|
||||||
|
|
||||||
|
Add phone OTP as primary login method for Vietnamese users.
|
||||||
|
Simplifies sign-up process vs password login.
|
||||||
|
|
||||||
|
- Add OTP request endpoint: POST /auth/otp/request
|
||||||
|
- Add OTP verify endpoint: POST /auth/otp/verify
|
||||||
|
- Store OTP in Redis with 5min expiry
|
||||||
|
- Prevent brute force: max 3 attempts per phone per hour
|
||||||
|
- Add unit tests for OTP generation and verification
|
||||||
|
|
||||||
|
Fixes #GOO-11
|
||||||
|
Co-Authored-By: Paperclip <noreply@paperclip.ing>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kiểm Tra Commit Message
|
||||||
|
|
||||||
|
Dùng `husky` pre-commit hook:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Tự động chạy khi git commit
|
||||||
|
# Kiểm tra:
|
||||||
|
# - Format conventional commits
|
||||||
|
# - No secrets (API keys, passwords)
|
||||||
|
# - Lint, typecheck
|
||||||
|
|
||||||
|
# Nếu hook thất bại, fix và commit lại
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pull Request Template
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Summary
|
||||||
|
Một dòng mô tả PR (tương tự commit subject).
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- Điểm thay đổi 1
|
||||||
|
- Điểm thay đổi 2
|
||||||
|
- Điểm thay đổi 3
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- [ ] Unit tests written
|
||||||
|
- [ ] E2E tests written (if applicable)
|
||||||
|
- [ ] Manual testing: describe steps
|
||||||
|
- [ ] No regressions found
|
||||||
|
|
||||||
|
## Screenshots / Logs (if applicable)
|
||||||
|
Paste images or log outputs.
|
||||||
|
|
||||||
|
## Related Issues
|
||||||
|
Fixes #GOO-7
|
||||||
|
Related to #GOO-5
|
||||||
|
|
||||||
|
## Notes for Reviewers
|
||||||
|
- Pay attention to X because Y
|
||||||
|
- Known limitations: Z
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Quy Ước Xử Lý Lỗi
|
## Quy Ước Xử Lý Lỗi
|
||||||
|
|
||||||
### Tổng Quan
|
### Tổng Quan
|
||||||
@@ -90,3 +294,84 @@ try {
|
|||||||
Tất cả các phương thức đọc của repository phải trả về DTOs được định kiểu rõ ràng — không bao giờ dùng `Promise<any>` hoặc `PaginatedResult<any>`. Định nghĩa read DTOs ở tầng domain cùng với interface của repository.
|
Tất cả các phương thức đọc của repository phải trả về DTOs được định kiểu rõ ràng — không bao giờ dùng `Promise<any>` hoặc `PaginatedResult<any>`. Định nghĩa read DTOs ở tầng domain cùng với interface của repository.
|
||||||
|
|
||||||
Xem `listing-read.dto.ts` để tham khảo ví dụ chuẩn.
|
Xem `listing-read.dto.ts` để tham khảo ví dụ chuẩn.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Review Checklist
|
||||||
|
|
||||||
|
Khi review PR, kiểm tra:
|
||||||
|
|
||||||
|
### Functionality
|
||||||
|
- [ ] Changes meet acceptance criteria
|
||||||
|
- [ ] No breaking changes (or documented)
|
||||||
|
- [ ] Error handling is robust
|
||||||
|
- [ ] Edge cases covered
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
- [ ] Code follows conventions (style, naming, patterns)
|
||||||
|
- [ ] No `console.log`, `TODO` without issue reference
|
||||||
|
- [ ] No dead code, unused imports
|
||||||
|
- [ ] Functions have clear responsibility
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- [ ] Unit tests cover happy path + error cases
|
||||||
|
- [ ] E2E tests for critical flows (if applicable)
|
||||||
|
- [ ] Coverage maintained / improved (API ≥60%, Web ≥50%)
|
||||||
|
- [ ] No flaky tests
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- [ ] Code comments explain "why", not "what"
|
||||||
|
- [ ] Updated docs if API/process changed
|
||||||
|
- [ ] Commit messages follow conventions
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- [ ] No hardcoded secrets (API keys, passwords)
|
||||||
|
- [ ] Input validation in place
|
||||||
|
- [ ] Auth checks in place
|
||||||
|
- [ ] No SQL injection (use Prisma, not raw SQL)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- [ ] No N+1 queries
|
||||||
|
- [ ] Caching applied where appropriate
|
||||||
|
- [ ] No blocking operations in event loop
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Release Process
|
||||||
|
|
||||||
|
### Versioning
|
||||||
|
|
||||||
|
Tuân theo **Semantic Versioning**: `MAJOR.MINOR.PATCH`
|
||||||
|
|
||||||
|
- **MAJOR:** Breaking changes (require migration)
|
||||||
|
- **MINOR:** New features (backward compatible)
|
||||||
|
- **PATCH:** Bug fixes
|
||||||
|
|
||||||
|
### Creating a Release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Update CHANGELOG.md with changes
|
||||||
|
# 2. Bump version in package.json (root)
|
||||||
|
# 3. Create git tag
|
||||||
|
git tag -a v1.5.0 -m "Release 1.5.0: Add phone OTP login"
|
||||||
|
git push origin v1.5.0
|
||||||
|
|
||||||
|
# 4. GitHub Actions automatically:
|
||||||
|
# - Builds Docker image
|
||||||
|
# - Pushes to GitHub Container Registry
|
||||||
|
# - Creates GitHub Release
|
||||||
|
# - Deploys to staging (auto)
|
||||||
|
# - Waits for manual approval for production
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
- 📖 Read `/docs/architecture.md` for system design
|
||||||
|
- 🏗️ Read `/docs/QUICK_REFERENCE.md` for patterns
|
||||||
|
- 💬 Ask on Slack `#dev` channel
|
||||||
|
- 🐛 File an issue: https://github.com/hongochai10/goodgo-bds-platform-ai/issues
|
||||||
|
|
||||||
|
**Happy coding! 🚀**
|
||||||
|
|
||||||
|
|||||||
@@ -194,4 +194,47 @@ describe('Auth Controller (Integration)', () => {
|
|||||||
.expect(401);
|
.expect(401);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('POST /auth/exchange-token', () => {
|
||||||
|
let validAccessToken: string;
|
||||||
|
let validRefreshToken: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post('/auth/login')
|
||||||
|
.send({
|
||||||
|
phone: '0912345678',
|
||||||
|
password: 'StrongPass123',
|
||||||
|
});
|
||||||
|
validAccessToken = res.body.accessToken as string;
|
||||||
|
validRefreshToken = res.body.refreshToken as string;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set auth cookies for a valid token pair', async () => {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post('/auth/exchange-token')
|
||||||
|
.send({ accessToken: validAccessToken, refreshToken: validRefreshToken })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(res.body.message).toBe('Auth cookies set');
|
||||||
|
const setCookie = res.headers['set-cookie'] as string[] | string;
|
||||||
|
const cookieStr = Array.isArray(setCookie) ? setCookie.join('; ') : (setCookie ?? '');
|
||||||
|
expect(cookieStr).toContain('access_token=');
|
||||||
|
expect(cookieStr).toContain('refresh_token=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 for an invalid access token', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/auth/exchange-token')
|
||||||
|
.send({ accessToken: 'invalid.token.here', refreshToken: validRefreshToken })
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when accessToken is missing', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/auth/exchange-token')
|
||||||
|
.send({ refreshToken: validRefreshToken })
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface UserProps {
|
|||||||
kycStatus: KYCStatus;
|
kycStatus: KYCStatus;
|
||||||
kycData: unknown;
|
kycData: unknown;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
deletedAt: Date | null;
|
||||||
totpSecret: string | null;
|
totpSecret: string | null;
|
||||||
totpEnabled: boolean;
|
totpEnabled: boolean;
|
||||||
totpBackupCodes: string[];
|
totpBackupCodes: string[];
|
||||||
@@ -33,6 +34,7 @@ export class UserEntity extends AggregateRoot<string> {
|
|||||||
private _kycStatus: KYCStatus;
|
private _kycStatus: KYCStatus;
|
||||||
private _kycData: unknown;
|
private _kycData: unknown;
|
||||||
private _isActive: boolean;
|
private _isActive: boolean;
|
||||||
|
private _deletedAt: Date | null;
|
||||||
private _totpSecret: string | null;
|
private _totpSecret: string | null;
|
||||||
private _totpEnabled: boolean;
|
private _totpEnabled: boolean;
|
||||||
private _totpBackupCodes: string[];
|
private _totpBackupCodes: string[];
|
||||||
@@ -49,6 +51,7 @@ export class UserEntity extends AggregateRoot<string> {
|
|||||||
this._kycStatus = props.kycStatus;
|
this._kycStatus = props.kycStatus;
|
||||||
this._kycData = props.kycData;
|
this._kycData = props.kycData;
|
||||||
this._isActive = props.isActive;
|
this._isActive = props.isActive;
|
||||||
|
this._deletedAt = props.deletedAt;
|
||||||
this._totpSecret = props.totpSecret;
|
this._totpSecret = props.totpSecret;
|
||||||
this._totpEnabled = props.totpEnabled;
|
this._totpEnabled = props.totpEnabled;
|
||||||
this._totpBackupCodes = props.totpBackupCodes;
|
this._totpBackupCodes = props.totpBackupCodes;
|
||||||
@@ -64,6 +67,7 @@ export class UserEntity extends AggregateRoot<string> {
|
|||||||
get kycStatus(): KYCStatus { return this._kycStatus; }
|
get kycStatus(): KYCStatus { return this._kycStatus; }
|
||||||
get kycData(): unknown { return this._kycData; }
|
get kycData(): unknown { return this._kycData; }
|
||||||
get isActive(): boolean { return this._isActive; }
|
get isActive(): boolean { return this._isActive; }
|
||||||
|
get deletedAt(): Date | null { return this._deletedAt; }
|
||||||
get totpSecret(): string | null { return this._totpSecret; }
|
get totpSecret(): string | null { return this._totpSecret; }
|
||||||
get totpEnabled(): boolean { return this._totpEnabled; }
|
get totpEnabled(): boolean { return this._totpEnabled; }
|
||||||
get totpBackupCodes(): string[] { return this._totpBackupCodes; }
|
get totpBackupCodes(): string[] { return this._totpBackupCodes; }
|
||||||
@@ -87,6 +91,44 @@ export class UserEntity extends AggregateRoot<string> {
|
|||||||
kycStatus: 'NONE',
|
kycStatus: 'NONE',
|
||||||
kycData: null,
|
kycData: null,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
deletedAt: null,
|
||||||
|
totpSecret: null,
|
||||||
|
totpEnabled: false,
|
||||||
|
totpBackupCodes: [],
|
||||||
|
totpEnabledAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
user.addDomainEvent(new UserRegisteredEvent(id, phone.value, role));
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a passwordless user (e.g. via Phone-OTP login auto-register).
|
||||||
|
* `passwordHash` is null so password login is not possible until the user
|
||||||
|
* sets one via the password-reset / profile flow. A fullName fallback is
|
||||||
|
* used since OTP signup does not collect a name.
|
||||||
|
*/
|
||||||
|
static createPasswordless(
|
||||||
|
id: string,
|
||||||
|
phone: Phone,
|
||||||
|
fullName?: string,
|
||||||
|
role: UserRole = 'BUYER',
|
||||||
|
): UserEntity {
|
||||||
|
const displayName =
|
||||||
|
fullName && fullName.trim().length > 0
|
||||||
|
? fullName
|
||||||
|
: `Người dùng ${phone.value.slice(-4)}`;
|
||||||
|
const user = new UserEntity(id, {
|
||||||
|
email: null,
|
||||||
|
phone,
|
||||||
|
passwordHash: null,
|
||||||
|
fullName: displayName,
|
||||||
|
avatarUrl: null,
|
||||||
|
role,
|
||||||
|
kycStatus: 'NONE',
|
||||||
|
kycData: null,
|
||||||
|
isActive: true,
|
||||||
|
deletedAt: null,
|
||||||
totpSecret: null,
|
totpSecret: null,
|
||||||
totpEnabled: false,
|
totpEnabled: false,
|
||||||
totpBackupCodes: [],
|
totpBackupCodes: [],
|
||||||
|
|||||||
@@ -22,56 +22,199 @@ vi.mock('@nestjs/passport', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Stub shared module imports so tests don't have to wire real Prisma/Redis.
|
||||||
|
vi.mock('@modules/shared', () => ({
|
||||||
|
PrismaService: class {},
|
||||||
|
RedisService: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
type PrismaStub = { user: { findUnique: ReturnType<typeof vi.fn> } };
|
||||||
|
type RedisStub = {
|
||||||
|
isAvailable: ReturnType<typeof vi.fn>;
|
||||||
|
get: ReturnType<typeof vi.fn>;
|
||||||
|
set: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makePrisma(user: { isActive: boolean; deletedAt: Date | null } | null): PrismaStub {
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(user),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRedis(options: { available?: boolean; cached?: string | null } = {}): RedisStub {
|
||||||
|
const { available = true, cached = null } = options;
|
||||||
|
return {
|
||||||
|
isAvailable: vi.fn().mockReturnValue(available),
|
||||||
|
get: vi.fn().mockResolvedValue(cached),
|
||||||
|
set: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIVE_USER = { isActive: true, deletedAt: null };
|
||||||
|
const BANNED_USER = { isActive: false, deletedAt: null };
|
||||||
|
const DELETED_USER = { isActive: true, deletedAt: new Date('2026-01-01T00:00:00Z') };
|
||||||
|
|
||||||
describe('JwtStrategy', () => {
|
describe('JwtStrategy', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws if JWT_SECRET is missing', async () => {
|
it('throws if JWT_SECRET is missing', async () => {
|
||||||
vi.stubEnv('JWT_SECRET', '');
|
vi.stubEnv('JWT_SECRET', '');
|
||||||
expect(async () => {
|
await expect(async () => {
|
||||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||||
new JwtStrategy();
|
new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never);
|
||||||
}).rejects.toThrow('JWT_SECRET environment variable is required');
|
}).rejects.toThrow('JWT_SECRET environment variable is required');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates strategy when JWT_SECRET is set', async () => {
|
it('creates strategy when JWT_SECRET is set', async () => {
|
||||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||||
const strategy = new JwtStrategy();
|
const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never);
|
||||||
expect(strategy).toBeDefined();
|
expect(strategy).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('validate returns correct payload shape', async () => {
|
it('validate returns the payload when user is active and not deleted', async () => {
|
||||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||||
const strategy = new JwtStrategy();
|
const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never);
|
||||||
|
|
||||||
const payload = { sub: 'user-1', phone: '+84912345678', role: 'BUYER', iat: 12345, exp: 99999 };
|
const payload = { sub: 'user-1', phone: '+84912345678', role: 'BUYER', iat: 12345, exp: 99999 };
|
||||||
const result = strategy.validate(payload);
|
const result = await strategy.validate(payload);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({ sub: 'user-1', phone: '+84912345678', role: 'BUYER' });
|
||||||
sub: 'user-1',
|
|
||||||
phone: '+84912345678',
|
|
||||||
role: 'BUYER',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('validate strips extra fields from payload', async () => {
|
it('validate strips extra fields from payload', async () => {
|
||||||
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||||
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||||
const strategy = new JwtStrategy();
|
const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never);
|
||||||
|
|
||||||
const payload = { sub: 'user-2', phone: '+84987654321', role: 'ADMIN', iat: 12345, exp: 99999, extra: 'data' } as any;
|
const payload = {
|
||||||
const result = strategy.validate(payload);
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
sub: 'user-2',
|
sub: 'user-2',
|
||||||
phone: '+84987654321',
|
phone: '+84987654321',
|
||||||
role: 'ADMIN',
|
role: 'ADMIN',
|
||||||
});
|
iat: 12345,
|
||||||
|
exp: 99999,
|
||||||
|
extra: 'data',
|
||||||
|
} as any;
|
||||||
|
const result = await strategy.validate(payload);
|
||||||
|
|
||||||
|
expect(result).toEqual({ sub: 'user-2', phone: '+84987654321', role: 'ADMIN' });
|
||||||
expect(result).not.toHaveProperty('extra');
|
expect(result).not.toHaveProperty('extra');
|
||||||
expect(result).not.toHaveProperty('iat');
|
expect(result).not.toHaveProperty('iat');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects banned user (isActive=false) with 401', async () => {
|
||||||
|
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||||
|
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||||
|
const prisma = makePrisma(BANNED_USER);
|
||||||
|
const strategy = new JwtStrategy(prisma as never, makeRedis() as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
strategy.validate({ sub: 'banned-1', phone: '+84911111111', role: 'BUYER' }),
|
||||||
|
).rejects.toMatchObject({ status: 401 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects soft-deleted user (deletedAt !== null) with 401', async () => {
|
||||||
|
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||||
|
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||||
|
const strategy = new JwtStrategy(makePrisma(DELETED_USER) as never, makeRedis() as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
strategy.validate({ sub: 'deleted-1', phone: '+84922222222', role: 'BUYER' }),
|
||||||
|
).rejects.toMatchObject({ status: 401 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when user does not exist in DB', async () => {
|
||||||
|
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||||
|
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||||
|
const strategy = new JwtStrategy(makePrisma(null) as never, makeRedis() as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
strategy.validate({ sub: 'ghost-1', phone: '+84933333333', role: 'BUYER' }),
|
||||||
|
).rejects.toMatchObject({ status: 401 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves user status from Redis cache when present (no DB hit)', async () => {
|
||||||
|
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||||
|
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||||
|
const prisma = makePrisma(ACTIVE_USER);
|
||||||
|
const redis = makeRedis({
|
||||||
|
available: true,
|
||||||
|
cached: JSON.stringify({ isActive: true, deletedAt: null }),
|
||||||
|
});
|
||||||
|
const strategy = new JwtStrategy(prisma as never, redis as never);
|
||||||
|
|
||||||
|
const result = await strategy.validate({ sub: 'user-cached', phone: '+84900000001', role: 'BUYER' });
|
||||||
|
expect(result.sub).toBe('user-cached');
|
||||||
|
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||||
|
expect(redis.get).toHaveBeenCalled();
|
||||||
|
expect(redis.set).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('populates Redis cache with 60s TTL after DB lookup', async () => {
|
||||||
|
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||||
|
const { JwtStrategy, USER_STATUS_CACHE_TTL_SECONDS, USER_STATUS_CACHE_PREFIX } = await import(
|
||||||
|
'../strategies/jwt.strategy'
|
||||||
|
);
|
||||||
|
const prisma = makePrisma(ACTIVE_USER);
|
||||||
|
const redis = makeRedis({ available: true, cached: null });
|
||||||
|
const strategy = new JwtStrategy(prisma as never, redis as never);
|
||||||
|
|
||||||
|
await strategy.validate({ sub: 'user-miss', phone: '+84900000002', role: 'BUYER' });
|
||||||
|
|
||||||
|
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
|
||||||
|
expect(redis.set).toHaveBeenCalledTimes(1);
|
||||||
|
const [key, value, ttl] = redis.set.mock.calls[0];
|
||||||
|
expect(key).toBe(`${USER_STATUS_CACHE_PREFIX}:user-miss`);
|
||||||
|
expect(JSON.parse(value)).toEqual({ isActive: true, deletedAt: null });
|
||||||
|
expect(ttl).toBe(USER_STATUS_CACHE_TTL_SECONDS);
|
||||||
|
expect(USER_STATUS_CACHE_TTL_SECONDS).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to DB when Redis is unavailable', async () => {
|
||||||
|
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||||
|
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||||
|
const prisma = makePrisma(ACTIVE_USER);
|
||||||
|
const redis = makeRedis({ available: false });
|
||||||
|
const strategy = new JwtStrategy(prisma as never, redis as never);
|
||||||
|
|
||||||
|
const result = await strategy.validate({ sub: 'user-rdown', phone: '+84900000003', role: 'BUYER' });
|
||||||
|
expect(result.sub).toBe('user-rdown');
|
||||||
|
expect(redis.get).not.toHaveBeenCalled();
|
||||||
|
expect(redis.set).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to DB when Redis read throws', async () => {
|
||||||
|
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||||
|
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||||
|
const prisma = makePrisma(ACTIVE_USER);
|
||||||
|
const redis = makeRedis({ available: true });
|
||||||
|
redis.get.mockRejectedValueOnce(new Error('boom'));
|
||||||
|
const strategy = new JwtStrategy(prisma as never, redis as never);
|
||||||
|
|
||||||
|
const result = await strategy.validate({ sub: 'user-rerr', phone: '+84900000004', role: 'BUYER' });
|
||||||
|
expect(result.sub).toBe('user-rerr');
|
||||||
|
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still rejects banned user when served from Redis cache', async () => {
|
||||||
|
vi.stubEnv('JWT_SECRET', 'test-secret-key');
|
||||||
|
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||||
|
const redis = makeRedis({
|
||||||
|
available: true,
|
||||||
|
cached: JSON.stringify({ isActive: false, deletedAt: null }),
|
||||||
|
});
|
||||||
|
const strategy = new JwtStrategy(makePrisma(null) as never, redis as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
strategy.validate({ sub: 'banned-cached', phone: '+84900000005', role: 'BUYER' }),
|
||||||
|
).rejects.toMatchObject({ status: 401 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ describe('LocalStrategy', () => {
|
|||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
passwordHash: null,
|
passwordHash: null,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
deletedAt: null,
|
||||||
phone: { value: '+84912345678' },
|
phone: { value: '+84912345678' },
|
||||||
role: 'BUYER',
|
role: 'BUYER',
|
||||||
});
|
});
|
||||||
@@ -101,6 +102,7 @@ describe('LocalStrategy', () => {
|
|||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
passwordHash: { compare: vi.fn().mockResolvedValue(true) },
|
passwordHash: { compare: vi.fn().mockResolvedValue(true) },
|
||||||
isActive: false,
|
isActive: false,
|
||||||
|
deletedAt: null,
|
||||||
phone: { value: '+84912345678' },
|
phone: { value: '+84912345678' },
|
||||||
role: 'BUYER',
|
role: 'BUYER',
|
||||||
});
|
});
|
||||||
@@ -110,11 +112,27 @@ describe('LocalStrategy', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('throws 401 when user is soft-deleted (deletedAt set)', async () => {
|
||||||
|
mockUserRepo.findByPhone.mockResolvedValue({
|
||||||
|
id: 'user-1',
|
||||||
|
passwordHash: { compare: vi.fn().mockResolvedValue(true) },
|
||||||
|
isActive: true,
|
||||||
|
deletedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
phone: { value: '+84912345678' },
|
||||||
|
role: 'BUYER',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(strategy.validate('0912345678', 'password')).rejects.toThrow(
|
||||||
|
'Tài khoản đã bị xóa',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('throws when password is wrong', async () => {
|
it('throws when password is wrong', async () => {
|
||||||
mockUserRepo.findByPhone.mockResolvedValue({
|
mockUserRepo.findByPhone.mockResolvedValue({
|
||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
passwordHash: { compare: vi.fn().mockResolvedValue(false) },
|
passwordHash: { compare: vi.fn().mockResolvedValue(false) },
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
deletedAt: null,
|
||||||
phone: { value: '+84912345678' },
|
phone: { value: '+84912345678' },
|
||||||
role: 'BUYER',
|
role: 'BUYER',
|
||||||
});
|
});
|
||||||
@@ -129,6 +147,8 @@ describe('LocalStrategy', () => {
|
|||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
passwordHash: { compare: vi.fn().mockResolvedValue(true) },
|
passwordHash: { compare: vi.fn().mockResolvedValue(true) },
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
deletedAt: null,
|
||||||
|
totpEnabled: false,
|
||||||
phone: { value: '+84912345678' },
|
phone: { value: '+84912345678' },
|
||||||
role: 'BUYER',
|
role: 'BUYER',
|
||||||
});
|
});
|
||||||
@@ -139,6 +159,7 @@ describe('LocalStrategy', () => {
|
|||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
phone: '+84912345678',
|
phone: '+84912345678',
|
||||||
role: 'BUYER',
|
role: 'BUYER',
|
||||||
|
isMfaRequired: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -173,6 +194,7 @@ describe('LocalStrategy', () => {
|
|||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
passwordHash: { compare: vi.fn().mockRejectedValue(new Error('bcrypt internal error')) },
|
passwordHash: { compare: vi.fn().mockRejectedValue(new Error('bcrypt internal error')) },
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
deletedAt: null,
|
||||||
phone: { value: '+84912345678' },
|
phone: { value: '+84912345678' },
|
||||||
role: 'BUYER',
|
role: 'BUYER',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ export class PrismaUserRepository implements IUserRepository {
|
|||||||
kycStatus: raw.kycStatus,
|
kycStatus: raw.kycStatus,
|
||||||
kycData: raw.kycData,
|
kycData: raw.kycData,
|
||||||
isActive: raw.isActive,
|
isActive: raw.isActive,
|
||||||
|
deletedAt: raw.deletedAt,
|
||||||
totpSecret: raw.totpSecret,
|
totpSecret: raw.totpSecret,
|
||||||
totpEnabled: raw.totpEnabled,
|
totpEnabled: raw.totpEnabled,
|
||||||
totpBackupCodes: raw.totpBackupCodes,
|
totpBackupCodes: raw.totpBackupCodes,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { type Request } from 'express';
|
import { type Request } from 'express';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||||
|
import { PrismaService, RedisService } from '@modules/shared';
|
||||||
import { type JwtPayload } from '../services/token.service';
|
import { type JwtPayload } from '../services/token.service';
|
||||||
|
|
||||||
function extractJwtFromCookieOrHeader(req: Request): string | null {
|
function extractJwtFromCookieOrHeader(req: Request): string | null {
|
||||||
@@ -10,9 +12,26 @@ function extractJwtFromCookieOrHeader(req: Request): string | null {
|
|||||||
return ExtractJwt.fromAuthHeaderAsBearerToken()(req);
|
return ExtractJwt.fromAuthHeaderAsBearerToken()(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cached user status — JSON encoded in Redis. */
|
||||||
|
interface CachedUserStatus {
|
||||||
|
isActive: boolean;
|
||||||
|
deletedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis key prefix for user status cache. Versioned so that a schema
|
||||||
|
* change can invalidate all stale entries by bumping the version.
|
||||||
|
*/
|
||||||
|
export const USER_STATUS_CACHE_PREFIX = 'auth:user_status:v1';
|
||||||
|
/** TTL for cached user status (seconds). */
|
||||||
|
export const USER_STATUS_CACHE_TTL_SECONDS = 60;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor() {
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
) {
|
||||||
const jwtSecret = process.env['JWT_SECRET'];
|
const jwtSecret = process.env['JWT_SECRET'];
|
||||||
if (!jwtSecret) {
|
if (!jwtSecret) {
|
||||||
throw new Error('JWT_SECRET environment variable is required');
|
throw new Error('JWT_SECRET environment variable is required');
|
||||||
@@ -27,7 +46,54 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
validate(payload: JwtPayload): JwtPayload {
|
async validate(payload: JwtPayload): Promise<JwtPayload> {
|
||||||
|
const status = await this.loadUserStatus(payload.sub);
|
||||||
|
if (!status || !status.isActive || status.deletedAt !== null) {
|
||||||
|
throw new UnauthorizedException('User account is inactive or deleted');
|
||||||
|
}
|
||||||
return { sub: payload.sub, phone: payload.phone, role: payload.role };
|
return { sub: payload.sub, phone: payload.phone, role: payload.role };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads user status from Redis cache if present, otherwise from DB and
|
||||||
|
* populates the cache with a 60 s TTL. Redis failures are non-fatal:
|
||||||
|
* we fall back to DB so a Redis outage cannot lock out all users.
|
||||||
|
*
|
||||||
|
* Returns null only when the user does not exist in the DB.
|
||||||
|
*/
|
||||||
|
private async loadUserStatus(userId: string): Promise<CachedUserStatus | null> {
|
||||||
|
const cacheKey = `${USER_STATUS_CACHE_PREFIX}:${userId}`;
|
||||||
|
|
||||||
|
if (this.redis.isAvailable()) {
|
||||||
|
try {
|
||||||
|
const cached = await this.redis.get(cacheKey);
|
||||||
|
if (cached !== null) {
|
||||||
|
return JSON.parse(cached) as CachedUserStatus;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Swallow: degrade to DB on Redis read error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { isActive: true, deletedAt: true },
|
||||||
|
});
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const status: CachedUserStatus = {
|
||||||
|
isActive: user.isActive,
|
||||||
|
deletedAt: user.deletedAt ? user.deletedAt.toISOString() : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.redis.isAvailable()) {
|
||||||
|
try {
|
||||||
|
await this.redis.set(cacheKey, JSON.stringify(status), USER_STATUS_CACHE_TTL_SECONDS);
|
||||||
|
} catch {
|
||||||
|
// Swallow: cache population is best-effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
|
|||||||
throw new UnauthorizedException('Tài khoản đã bị vô hiệu hóa');
|
throw new UnauthorizedException('Tài khoản đã bị vô hiệu hóa');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user.deletedAt !== null) {
|
||||||
|
throw new UnauthorizedException('Tài khoản đã bị xóa');
|
||||||
|
}
|
||||||
|
|
||||||
const isValid = await user.passwordHash.compare(password);
|
const isValid = await user.passwordHash.compare(password);
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
throw new UnauthorizedException('Số điện thoại hoặc mật khẩu không đúng');
|
throw new UnauthorizedException('Số điện thoại hoặc mật khẩu không đúng');
|
||||||
|
|||||||
@@ -230,10 +230,14 @@ export class AuthController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: 20 } })
|
||||||
|
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'ip' })
|
||||||
|
@UseGuards(EndpointRateLimitGuard)
|
||||||
@Post('exchange-token')
|
@Post('exchange-token')
|
||||||
@ApiOperation({ summary: 'Exchange OAuth token pair for httpOnly cookies' })
|
@ApiOperation({ summary: 'Exchange OAuth token pair for httpOnly cookies' })
|
||||||
@ApiResponse({ status: 201, description: 'Auth cookies set' })
|
@ApiResponse({ status: 201, description: 'Auth cookies set' })
|
||||||
@ApiResponse({ status: 401, description: 'Invalid access token' })
|
@ApiResponse({ status: 401, description: 'Invalid access token' })
|
||||||
|
@ApiResponse({ status: 429, description: 'Too many requests' })
|
||||||
async exchangeToken(
|
async exchangeToken(
|
||||||
@Body() body: { accessToken: string; refreshToken: string; expiresIn?: number },
|
@Body() body: { accessToken: string; refreshToken: string; expiresIn?: number },
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ const DEFAULT_RANGES: Record<PropertyType, { min: number; max: number }> = {
|
|||||||
LAND: { min: 5_000_000, max: 800_000_000 },
|
LAND: { min: 5_000_000, max: 800_000_000 },
|
||||||
OFFICE: { min: 10_000_000, max: 300_000_000 },
|
OFFICE: { min: 10_000_000, max: 300_000_000 },
|
||||||
SHOPHOUSE: { min: 30_000_000, max: 600_000_000 },
|
SHOPHOUSE: { min: 30_000_000, max: 600_000_000 },
|
||||||
|
// Phòng trọ: priced per month (1M–10M VND), stored as total price (not per-m²).
|
||||||
|
// Range reflects typical HCMC room rental market 2024-2026.
|
||||||
|
ROOM_RENTAL: { min: 1_000_000, max: 10_000_000 },
|
||||||
|
// Condotel: mixed-use hotel/condo; higher-end per-m² due to resort factor.
|
||||||
|
CONDOTEL: { min: 20_000_000, max: 300_000_000 },
|
||||||
|
// Serviced apartment: furnished with hotel-style services; premium over standard apartments.
|
||||||
|
SERVICED_APARTMENT: { min: 20_000_000, max: 250_000_000 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Multiplier to widen default ranges for suspicious-but-not-invalid detection */
|
/** Multiplier to widen default ranges for suspicious-but-not-invalid detection */
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ describe('MomoService', () => {
|
|||||||
const secretKey = 'TESTSECRETKEY123456789012345678';
|
const secretKey = 'TESTSECRETKEY123456789012345678';
|
||||||
const partnerCode = 'TESTPARTNER';
|
const partnerCode = 'TESTPARTNER';
|
||||||
const accessKey = 'TESTACCESSKEY';
|
const accessKey = 'TESTACCESSKEY';
|
||||||
|
const callbackBaseUrl = 'https://api.goodgo.vn';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const mockConfig = {
|
const mockConfig = {
|
||||||
@@ -16,6 +17,7 @@ describe('MomoService', () => {
|
|||||||
'MOMO_PARTNER_CODE': 'TESTPARTNER',
|
'MOMO_PARTNER_CODE': 'TESTPARTNER',
|
||||||
'MOMO_ACCESS_KEY': 'TESTACCESSKEY',
|
'MOMO_ACCESS_KEY': 'TESTACCESSKEY',
|
||||||
'MOMO_SECRET_KEY': 'TESTSECRETKEY123456789012345678',
|
'MOMO_SECRET_KEY': 'TESTSECRETKEY123456789012345678',
|
||||||
|
'PAYMENT_CALLBACK_BASE_URL': callbackBaseUrl,
|
||||||
};
|
};
|
||||||
return env[key] ?? defaultValue;
|
return env[key] ?? defaultValue;
|
||||||
}),
|
}),
|
||||||
@@ -117,4 +119,50 @@ describe('MomoService', () => {
|
|||||||
expect(result.isValid).toBe(true);
|
expect(result.isValid).toBe(true);
|
||||||
expect(result.isSuccess).toBe(false);
|
expect(result.isSuccess).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('createPaymentUrl should use backend IPN URL for ipnUrl and frontend URL for redirectUrl', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
json: async () => ({ resultCode: 0, payUrl: 'https://pay.momo.vn/abc' }),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const returnUrl = 'https://goodgo.vn/payment/return';
|
||||||
|
await service.createPaymentUrl({
|
||||||
|
orderId: 'order-xyz',
|
||||||
|
amountVND: 100000n,
|
||||||
|
description: 'Test',
|
||||||
|
returnUrl,
|
||||||
|
ipAddress: '127.0.0.1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
||||||
|
expect(body.redirectUrl).toBe(returnUrl);
|
||||||
|
expect(body.ipnUrl).toBe(`${callbackBaseUrl}/api/v1/payments/callback/momo`);
|
||||||
|
expect(body.ipnUrl).not.toBe(body.redirectUrl);
|
||||||
|
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createPaymentUrl should use provided callbackUrl as ipnUrl when supplied', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
json: async () => ({ resultCode: 0, payUrl: 'https://pay.momo.vn/abc' }),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const customCallback = 'https://staging-api.goodgo.vn/api/v1/payments/callback/momo';
|
||||||
|
await service.createPaymentUrl({
|
||||||
|
orderId: 'order-xyz',
|
||||||
|
amountVND: 100000n,
|
||||||
|
description: 'Test',
|
||||||
|
returnUrl: 'https://goodgo.vn/payment/return',
|
||||||
|
callbackUrl: customCallback,
|
||||||
|
ipAddress: '127.0.0.1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
||||||
|
expect(body.ipnUrl).toBe(customCallback);
|
||||||
|
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ZalopayService } from '../services/zalopay.service';
|
|||||||
describe('ZalopayService', () => {
|
describe('ZalopayService', () => {
|
||||||
let service: ZalopayService;
|
let service: ZalopayService;
|
||||||
const key2 = 'TESTKEY2ABCDEF1234567890ABCDEF12';
|
const key2 = 'TESTKEY2ABCDEF1234567890ABCDEF12';
|
||||||
|
const callbackBaseUrl = 'https://api.goodgo.vn';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const mockConfig = {
|
const mockConfig = {
|
||||||
@@ -14,6 +15,7 @@ describe('ZalopayService', () => {
|
|||||||
'ZALOPAY_APP_ID': '2553',
|
'ZALOPAY_APP_ID': '2553',
|
||||||
'ZALOPAY_KEY1': 'TESTKEY1ABCDEF1234567890ABCDEF12',
|
'ZALOPAY_KEY1': 'TESTKEY1ABCDEF1234567890ABCDEF12',
|
||||||
'ZALOPAY_KEY2': 'TESTKEY2ABCDEF1234567890ABCDEF12',
|
'ZALOPAY_KEY2': 'TESTKEY2ABCDEF1234567890ABCDEF12',
|
||||||
|
'PAYMENT_CALLBACK_BASE_URL': callbackBaseUrl,
|
||||||
};
|
};
|
||||||
return env[key] ?? defaultValue;
|
return env[key] ?? defaultValue;
|
||||||
}),
|
}),
|
||||||
@@ -99,4 +101,51 @@ describe('ZalopayService', () => {
|
|||||||
expect(result.isValid).toBe(false);
|
expect(result.isValid).toBe(false);
|
||||||
expect(result.isSuccess).toBe(false);
|
expect(result.isSuccess).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('createPaymentUrl should use backend URL for callback_url and frontend URL in embed_data.redirecturl', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
json: async () => ({ return_code: 1, order_url: 'https://zalopay.vn/pay', zp_trans_token: 'tok' }),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const returnUrl = 'https://goodgo.vn/payment/return';
|
||||||
|
await service.createPaymentUrl({
|
||||||
|
orderId: 'order-xyz',
|
||||||
|
amountVND: 100000n,
|
||||||
|
description: 'Test',
|
||||||
|
returnUrl,
|
||||||
|
ipAddress: '127.0.0.1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
||||||
|
expect(body.callback_url).toBe(`${callbackBaseUrl}/api/v1/payments/callback/zalopay`);
|
||||||
|
expect(body.callback_url).not.toBe(returnUrl);
|
||||||
|
const embedData = JSON.parse(body.embed_data as string);
|
||||||
|
expect(embedData.redirecturl).toBe(returnUrl);
|
||||||
|
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createPaymentUrl should use provided callbackUrl for callback_url when supplied', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
json: async () => ({ return_code: 1, order_url: 'https://zalopay.vn/pay', zp_trans_token: 'tok' }),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const customCallback = 'https://staging-api.goodgo.vn/api/v1/payments/callback/zalopay';
|
||||||
|
await service.createPaymentUrl({
|
||||||
|
orderId: 'order-xyz',
|
||||||
|
amountVND: 100000n,
|
||||||
|
description: 'Test',
|
||||||
|
returnUrl: 'https://goodgo.vn/payment/return',
|
||||||
|
callbackUrl: customCallback,
|
||||||
|
ipAddress: '127.0.0.1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
||||||
|
expect(body.callback_url).toBe(customCallback);
|
||||||
|
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export class MomoService implements IPaymentGateway {
|
|||||||
private readonly accessKey: string;
|
private readonly accessKey: string;
|
||||||
private readonly secretKey: string;
|
private readonly secretKey: string;
|
||||||
private readonly endpoint: string;
|
private readonly endpoint: string;
|
||||||
|
private readonly callbackBaseUrl: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
@@ -29,6 +30,7 @@ export class MomoService implements IPaymentGateway {
|
|||||||
this.accessKey = this.config.getOrThrow<string>('MOMO_ACCESS_KEY');
|
this.accessKey = this.config.getOrThrow<string>('MOMO_ACCESS_KEY');
|
||||||
this.secretKey = this.config.getOrThrow<string>('MOMO_SECRET_KEY');
|
this.secretKey = this.config.getOrThrow<string>('MOMO_SECRET_KEY');
|
||||||
this.endpoint = this.config.get<string>('MOMO_ENDPOINT', 'https://test-payment.momo.vn/v2/gateway/api');
|
this.endpoint = this.config.get<string>('MOMO_ENDPOINT', 'https://test-payment.momo.vn/v2/gateway/api');
|
||||||
|
this.callbackBaseUrl = this.config.get<string>('PAYMENT_CALLBACK_BASE_URL', 'https://api.goodgo.vn');
|
||||||
}
|
}
|
||||||
|
|
||||||
async createPaymentUrl(params: CreatePaymentUrlParams): Promise<CreatePaymentUrlResult> {
|
async createPaymentUrl(params: CreatePaymentUrlParams): Promise<CreatePaymentUrlResult> {
|
||||||
@@ -39,11 +41,14 @@ export class MomoService implements IPaymentGateway {
|
|||||||
const lang = 'vi';
|
const lang = 'vi';
|
||||||
const amount = params.amountVND.toString();
|
const amount = params.amountVND.toString();
|
||||||
|
|
||||||
|
// ipnUrl must be the backend server-to-server callback endpoint (never the frontend URL)
|
||||||
|
const ipnUrl = params.callbackUrl ?? `${this.callbackBaseUrl}/api/v1/payments/callback/momo`;
|
||||||
|
|
||||||
const rawSignature = [
|
const rawSignature = [
|
||||||
`accessKey=${this.accessKey}`,
|
`accessKey=${this.accessKey}`,
|
||||||
`amount=${amount}`,
|
`amount=${amount}`,
|
||||||
`extraData=${extraData}`,
|
`extraData=${extraData}`,
|
||||||
`ipnUrl=${params.returnUrl}`,
|
`ipnUrl=${ipnUrl}`,
|
||||||
`orderId=${params.orderId}`,
|
`orderId=${params.orderId}`,
|
||||||
`orderInfo=${params.description}`,
|
`orderInfo=${params.description}`,
|
||||||
`partnerCode=${this.partnerCode}`,
|
`partnerCode=${this.partnerCode}`,
|
||||||
@@ -66,7 +71,7 @@ export class MomoService implements IPaymentGateway {
|
|||||||
orderId: params.orderId,
|
orderId: params.orderId,
|
||||||
orderInfo: params.description,
|
orderInfo: params.description,
|
||||||
redirectUrl: params.returnUrl,
|
redirectUrl: params.returnUrl,
|
||||||
ipnUrl: params.returnUrl,
|
ipnUrl,
|
||||||
lang,
|
lang,
|
||||||
requestType,
|
requestType,
|
||||||
autoCapture,
|
autoCapture,
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ export interface CreatePaymentUrlParams {
|
|||||||
orderId: string;
|
orderId: string;
|
||||||
amountVND: bigint;
|
amountVND: bigint;
|
||||||
description: string;
|
description: string;
|
||||||
|
/** Frontend redirect URL shown to the user after payment */
|
||||||
returnUrl: string;
|
returnUrl: string;
|
||||||
|
/** Backend IPN / server-callback URL (overrides PAYMENT_CALLBACK_BASE_URL when provided) */
|
||||||
|
callbackUrl?: string;
|
||||||
ipAddress: string;
|
ipAddress: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export class ZalopayService implements IPaymentGateway {
|
|||||||
private readonly key1: string;
|
private readonly key1: string;
|
||||||
private readonly key2: string;
|
private readonly key2: string;
|
||||||
private readonly endpoint: string;
|
private readonly endpoint: string;
|
||||||
|
private readonly callbackBaseUrl: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
@@ -29,6 +30,7 @@ export class ZalopayService implements IPaymentGateway {
|
|||||||
this.key1 = this.config.getOrThrow<string>('ZALOPAY_KEY1');
|
this.key1 = this.config.getOrThrow<string>('ZALOPAY_KEY1');
|
||||||
this.key2 = this.config.getOrThrow<string>('ZALOPAY_KEY2');
|
this.key2 = this.config.getOrThrow<string>('ZALOPAY_KEY2');
|
||||||
this.endpoint = this.config.get<string>('ZALOPAY_ENDPOINT', 'https://sb-openapi.zalopay.vn/v2');
|
this.endpoint = this.config.get<string>('ZALOPAY_ENDPOINT', 'https://sb-openapi.zalopay.vn/v2');
|
||||||
|
this.callbackBaseUrl = this.config.get<string>('PAYMENT_CALLBACK_BASE_URL', 'https://api.goodgo.vn');
|
||||||
}
|
}
|
||||||
|
|
||||||
async createPaymentUrl(params: CreatePaymentUrlParams): Promise<CreatePaymentUrlResult> {
|
async createPaymentUrl(params: CreatePaymentUrlParams): Promise<CreatePaymentUrlResult> {
|
||||||
@@ -36,7 +38,10 @@ export class ZalopayService implements IPaymentGateway {
|
|||||||
const appTransId = `${this.formatYYMMDD(now)}_${params.orderId}`;
|
const appTransId = `${this.formatYYMMDD(now)}_${params.orderId}`;
|
||||||
const appTime = now.getTime();
|
const appTime = now.getTime();
|
||||||
const amount = Number(params.amountVND);
|
const amount = Number(params.amountVND);
|
||||||
|
|
||||||
|
// embed_data carries the frontend redirect URL; callback_url is the backend IPN endpoint
|
||||||
const embedData = JSON.stringify({ redirecturl: params.returnUrl });
|
const embedData = JSON.stringify({ redirecturl: params.returnUrl });
|
||||||
|
const callbackUrl = params.callbackUrl ?? `${this.callbackBaseUrl}/api/v1/payments/callback/zalopay`;
|
||||||
const items = JSON.stringify([]);
|
const items = JSON.stringify([]);
|
||||||
|
|
||||||
const data = [
|
const data = [
|
||||||
@@ -62,7 +67,7 @@ export class ZalopayService implements IPaymentGateway {
|
|||||||
item: items,
|
item: items,
|
||||||
description: params.description,
|
description: params.description,
|
||||||
embed_data: embedData,
|
embed_data: embedData,
|
||||||
callback_url: params.returnUrl,
|
callback_url: callbackUrl,
|
||||||
mac,
|
mac,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import type { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '@modules/shared';
|
import { PrismaService } from '@modules/shared';
|
||||||
import { ProjectDevelopmentEntity } from '../../domain/entities/project-development.entity';
|
import { ProjectDevelopmentEntity } from '../../domain/entities/project-development.entity';
|
||||||
import type {
|
import type {
|
||||||
@@ -132,58 +132,49 @@ export class PrismaProjectDevelopmentRepository implements IProjectRepository {
|
|||||||
const limit = params.limit ?? 20;
|
const limit = params.limit ?? 20;
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
const conditions: string[] = ['1=1'];
|
const clauses: Prisma.Sql[] = [Prisma.sql`1=1`];
|
||||||
const values: unknown[] = [];
|
|
||||||
let paramIndex = 1;
|
|
||||||
|
|
||||||
if (params.status) {
|
if (params.status) {
|
||||||
conditions.push(`status = $${paramIndex++}::"ProjectDevelopmentStatus"`);
|
clauses.push(Prisma.sql`status = ${params.status}::"ProjectDevelopmentStatus"`);
|
||||||
values.push(params.status);
|
|
||||||
}
|
}
|
||||||
if (params.city) {
|
if (params.city) {
|
||||||
conditions.push(`city = $${paramIndex++}`);
|
clauses.push(Prisma.sql`city = ${params.city}`);
|
||||||
values.push(params.city);
|
|
||||||
}
|
}
|
||||||
if (params.district) {
|
if (params.district) {
|
||||||
conditions.push(`district = $${paramIndex++}`);
|
clauses.push(Prisma.sql`district = ${params.district}`);
|
||||||
values.push(params.district);
|
|
||||||
}
|
}
|
||||||
if (params.developer) {
|
if (params.developer) {
|
||||||
conditions.push(`developer ILIKE $${paramIndex++}`);
|
clauses.push(Prisma.sql`developer ILIKE ${'%' + params.developer + '%'}`);
|
||||||
values.push(`%${params.developer}%`);
|
|
||||||
}
|
}
|
||||||
if (params.isVerified !== undefined) {
|
if (params.isVerified !== undefined) {
|
||||||
conditions.push(`"isVerified" = $${paramIndex++}`);
|
clauses.push(Prisma.sql`"isVerified" = ${params.isVerified}`);
|
||||||
values.push(params.isVerified);
|
|
||||||
}
|
}
|
||||||
if (params.ownerId) {
|
if (params.ownerId) {
|
||||||
conditions.push(`"ownerId" = $${paramIndex++}`);
|
clauses.push(Prisma.sql`"ownerId" = ${params.ownerId}`);
|
||||||
values.push(params.ownerId);
|
|
||||||
}
|
}
|
||||||
if (params.query) {
|
if (params.query) {
|
||||||
conditions.push(`(name ILIKE $${paramIndex} OR developer ILIKE $${paramIndex} OR district ILIKE $${paramIndex} OR city ILIKE $${paramIndex})`);
|
const like = `%${params.query}%`;
|
||||||
values.push(`%${params.query}%`);
|
clauses.push(Prisma.sql`(name ILIKE ${like} OR developer ILIKE ${like} OR district ILIKE ${like} OR city ILIKE ${like})`);
|
||||||
paramIndex++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const where = conditions.join(' AND ');
|
const where = Prisma.join(clauses, ' AND ');
|
||||||
|
|
||||||
const countResult = await this.prisma.$queryRawUnsafe<[{ count: bigint }]>(
|
const countResult = await this.prisma.$queryRaw<[{ count: bigint }]>(
|
||||||
`SELECT COUNT(*)::bigint as count FROM "ProjectDevelopment" WHERE ${where}`,
|
Prisma.sql`SELECT COUNT(*)::bigint as count FROM "ProjectDevelopment" WHERE ${where}`,
|
||||||
...values,
|
|
||||||
);
|
);
|
||||||
const total = Number(countResult[0].count);
|
const total = Number(countResult[0].count);
|
||||||
|
|
||||||
const rows = await this.prisma.$queryRawUnsafe<RawProjectDetail[]>(
|
const rows = await this.prisma.$queryRaw<RawProjectDetail[]>(
|
||||||
`SELECT p.*, ST_Y(p.location::geometry) as lat, ST_X(p.location::geometry) as lng,
|
Prisma.sql`
|
||||||
COUNT(pr.id)::int as "propertyCount"
|
SELECT p.*, ST_Y(p.location::geometry) as lat, ST_X(p.location::geometry) as lng,
|
||||||
FROM "ProjectDevelopment" p
|
COUNT(pr.id)::int as "propertyCount"
|
||||||
LEFT JOIN "Property" pr ON pr."projectDevelopmentId" = p.id
|
FROM "ProjectDevelopment" p
|
||||||
WHERE ${where.replace(/\b(\$\d+)/g, (_, m) => m)}
|
LEFT JOIN "Property" pr ON pr."projectDevelopmentId" = p.id
|
||||||
GROUP BY p.id
|
WHERE ${where}
|
||||||
ORDER BY p."createdAt" DESC
|
GROUP BY p.id
|
||||||
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
|
ORDER BY p."createdAt" DESC
|
||||||
...values, limit, offset,
|
LIMIT ${limit} OFFSET ${offset}
|
||||||
|
`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ describe('CheckQuotaHandler', () => {
|
|||||||
},
|
},
|
||||||
usageRecord: {
|
usageRecord: {
|
||||||
findFirst: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
|
findUnique: vi.fn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,7 +34,9 @@ describe('CheckQuotaHandler', () => {
|
|||||||
invalidateByPrefix: vi.fn().mockResolvedValue(undefined),
|
invalidateByPrefix: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
handler = new CheckQuotaHandler(mockRepo as any, mockPrisma, mockCache as any);
|
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
|
||||||
|
|
||||||
|
handler = new CheckQuotaHandler(mockRepo as any, mockPrisma, mockCache as any, mockLogger as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns quota for active subscription', async () => {
|
it('returns quota for active subscription', async () => {
|
||||||
@@ -48,7 +51,7 @@ describe('CheckQuotaHandler', () => {
|
|||||||
maxListings: 50,
|
maxListings: 50,
|
||||||
maxSavedSearches: 10,
|
maxSavedSearches: 10,
|
||||||
});
|
});
|
||||||
mockPrisma.usageRecord.findFirst.mockResolvedValue({ count: 15 });
|
mockPrisma.usageRecord.findUnique.mockResolvedValue({ count: 15 });
|
||||||
|
|
||||||
const query = new CheckQuotaQuery('user-1', 'listings_created');
|
const query = new CheckQuotaQuery('user-1', 'listings_created');
|
||||||
const result = await handler.execute(query);
|
const result = await handler.execute(query);
|
||||||
@@ -71,7 +74,7 @@ describe('CheckQuotaHandler', () => {
|
|||||||
id: 'plan-1',
|
id: 'plan-1',
|
||||||
maxListings: 5,
|
maxListings: 5,
|
||||||
});
|
});
|
||||||
mockPrisma.usageRecord.findFirst.mockResolvedValue({ count: 5 });
|
mockPrisma.usageRecord.findUnique.mockResolvedValue({ count: 5 });
|
||||||
|
|
||||||
const query = new CheckQuotaQuery('user-1', 'listings_created');
|
const query = new CheckQuotaQuery('user-1', 'listings_created');
|
||||||
const result = await handler.execute(query);
|
const result = await handler.execute(query);
|
||||||
|
|||||||
@@ -28,9 +28,7 @@ describe('MeterUsageHandler', () => {
|
|||||||
|
|
||||||
mockPrisma = {
|
mockPrisma = {
|
||||||
usageRecord: {
|
usageRecord: {
|
||||||
findFirst: vi.fn(),
|
upsert: vi.fn(),
|
||||||
create: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,11 +48,10 @@ describe('MeterUsageHandler', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates new usage record when none exists', async () => {
|
it('creates new usage record via atomic upsert when none exists', async () => {
|
||||||
const subscription = createActiveSubscription();
|
const subscription = createActiveSubscription();
|
||||||
mockRepo.findByUserId.mockResolvedValue(subscription);
|
mockRepo.findByUserId.mockResolvedValue(subscription);
|
||||||
mockPrisma.usageRecord.findFirst.mockResolvedValue(null);
|
mockPrisma.usageRecord.upsert.mockResolvedValue({
|
||||||
mockPrisma.usageRecord.create.mockResolvedValue({
|
|
||||||
id: 'usage-1',
|
id: 'usage-1',
|
||||||
metric: 'listings_created',
|
metric: 'listings_created',
|
||||||
count: 3,
|
count: 3,
|
||||||
@@ -68,17 +65,30 @@ describe('MeterUsageHandler', () => {
|
|||||||
expect(result.usageRecordId).toBe('usage-1');
|
expect(result.usageRecordId).toBe('usage-1');
|
||||||
expect(result.metric).toBe('listings_created');
|
expect(result.metric).toBe('listings_created');
|
||||||
expect(result.count).toBe(3);
|
expect(result.count).toBe(3);
|
||||||
expect(mockPrisma.usageRecord.create).toHaveBeenCalledTimes(1);
|
expect(mockPrisma.usageRecord.upsert).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
subscriptionId_metric_periodStart_periodEnd: {
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
metric: 'listings_created',
|
||||||
|
periodStart: subscription.currentPeriodStart,
|
||||||
|
periodEnd: subscription.currentPeriodEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: { count: { increment: 3 } },
|
||||||
|
create: {
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
metric: 'listings_created',
|
||||||
|
count: 3,
|
||||||
|
periodStart: subscription.currentPeriodStart,
|
||||||
|
periodEnd: subscription.currentPeriodEnd,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('increments existing usage record', async () => {
|
it('increments existing usage record via atomic upsert', async () => {
|
||||||
const subscription = createActiveSubscription();
|
const subscription = createActiveSubscription();
|
||||||
mockRepo.findByUserId.mockResolvedValue(subscription);
|
mockRepo.findByUserId.mockResolvedValue(subscription);
|
||||||
mockPrisma.usageRecord.findFirst.mockResolvedValue({
|
mockPrisma.usageRecord.upsert.mockResolvedValue({
|
||||||
id: 'usage-1',
|
|
||||||
count: 5,
|
|
||||||
});
|
|
||||||
mockPrisma.usageRecord.update.mockResolvedValue({
|
|
||||||
id: 'usage-1',
|
id: 'usage-1',
|
||||||
metric: 'listings_created',
|
metric: 'listings_created',
|
||||||
count: 8,
|
count: 8,
|
||||||
@@ -90,17 +100,13 @@ describe('MeterUsageHandler', () => {
|
|||||||
const result = await handler.execute(command);
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
expect(result.count).toBe(8);
|
expect(result.count).toBe(8);
|
||||||
expect(mockPrisma.usageRecord.update).toHaveBeenCalledWith({
|
expect(mockPrisma.usageRecord.upsert).toHaveBeenCalledTimes(1);
|
||||||
where: { id: 'usage-1' },
|
|
||||||
data: { count: 8 },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('invalidates quota cache after metering usage', async () => {
|
it('invalidates quota cache after metering usage', async () => {
|
||||||
const subscription = createActiveSubscription();
|
const subscription = createActiveSubscription();
|
||||||
mockRepo.findByUserId.mockResolvedValue(subscription);
|
mockRepo.findByUserId.mockResolvedValue(subscription);
|
||||||
mockPrisma.usageRecord.findFirst.mockResolvedValue(null);
|
mockPrisma.usageRecord.upsert.mockResolvedValue({
|
||||||
mockPrisma.usageRecord.create.mockResolvedValue({
|
|
||||||
id: 'usage-1',
|
id: 'usage-1',
|
||||||
metric: 'listings_created',
|
metric: 'listings_created',
|
||||||
count: 1,
|
count: 1,
|
||||||
|
|||||||
@@ -40,34 +40,28 @@ export class MeterUsageHandler implements ICommandHandler<MeterUsageCommand> {
|
|||||||
throw new ValidationException('Subscription không ở trạng thái hoạt động');
|
throw new ValidationException('Subscription không ở trạng thái hoạt động');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert usage record for current period + metric
|
// Atomic upsert using the @@unique constraint to prevent race conditions
|
||||||
const existing = await this.prisma.usageRecord.findFirst({
|
const usageRecord = await this.prisma.usageRecord.upsert({
|
||||||
where: {
|
where: {
|
||||||
|
subscriptionId_metric_periodStart_periodEnd: {
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
metric: command.metric,
|
||||||
|
periodStart: subscription.currentPeriodStart,
|
||||||
|
periodEnd: subscription.currentPeriodEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
count: { increment: command.count },
|
||||||
|
},
|
||||||
|
create: {
|
||||||
subscriptionId: subscription.id,
|
subscriptionId: subscription.id,
|
||||||
metric: command.metric,
|
metric: command.metric,
|
||||||
|
count: command.count,
|
||||||
periodStart: subscription.currentPeriodStart,
|
periodStart: subscription.currentPeriodStart,
|
||||||
periodEnd: subscription.currentPeriodEnd,
|
periodEnd: subscription.currentPeriodEnd,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let usageRecord;
|
|
||||||
if (existing) {
|
|
||||||
usageRecord = await this.prisma.usageRecord.update({
|
|
||||||
where: { id: existing.id },
|
|
||||||
data: { count: existing.count + command.count },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
usageRecord = await this.prisma.usageRecord.create({
|
|
||||||
data: {
|
|
||||||
subscriptionId: subscription.id,
|
|
||||||
metric: command.metric,
|
|
||||||
count: command.count,
|
|
||||||
periodStart: subscription.currentPeriodStart,
|
|
||||||
periodEnd: subscription.currentPeriodEnd,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalidate cached quota for this user + metric
|
// Invalidate cached quota for this user + metric
|
||||||
await this.cache.invalidate(
|
await this.cache.invalidate(
|
||||||
CacheService.buildKey(CachePrefix.USER_QUOTA, command.userId, command.metric),
|
CacheService.buildKey(CachePrefix.USER_QUOTA, command.userId, command.metric),
|
||||||
|
|||||||
@@ -76,13 +76,15 @@ export class CheckQuotaHandler implements IQueryHandler<CheckQuotaQuery> {
|
|||||||
throw new NotFoundException('Plan', subscription.planId);
|
throw new NotFoundException('Plan', subscription.planId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.checkAgainstPlan(plan, metric, subscription.id);
|
return this.checkAgainstPlan(plan, metric, subscription.id, subscription.currentPeriodStart, subscription.currentPeriodEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async checkAgainstPlan(
|
private async checkAgainstPlan(
|
||||||
plan: Plan,
|
plan: Plan,
|
||||||
metric: string,
|
metric: string,
|
||||||
subscriptionId: string | null,
|
subscriptionId: string | null,
|
||||||
|
periodStart?: Date,
|
||||||
|
periodEnd?: Date,
|
||||||
): Promise<QuotaCheckResult> {
|
): Promise<QuotaCheckResult> {
|
||||||
const planField = METRIC_TO_PLAN_FIELD[metric];
|
const planField = METRIC_TO_PLAN_FIELD[metric];
|
||||||
|
|
||||||
@@ -93,9 +95,22 @@ export class CheckQuotaHandler implements IQueryHandler<CheckQuotaQuery> {
|
|||||||
|
|
||||||
const limit = plan[planField] as number;
|
const limit = plan[planField] as number;
|
||||||
|
|
||||||
// Get current usage
|
// Get current period usage (period-scoped to prevent stale reads)
|
||||||
let used = 0;
|
let used = 0;
|
||||||
if (subscriptionId) {
|
if (subscriptionId && periodStart && periodEnd) {
|
||||||
|
const usageRecord = await this.prisma.usageRecord.findUnique({
|
||||||
|
where: {
|
||||||
|
subscriptionId_metric_periodStart_periodEnd: {
|
||||||
|
subscriptionId,
|
||||||
|
metric,
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
used = usageRecord?.count ?? 0;
|
||||||
|
} else if (subscriptionId) {
|
||||||
|
// Fallback for free tier (no subscription period)
|
||||||
const usageRecord = await this.prisma.usageRecord.findFirst({
|
const usageRecord = await this.prisma.usageRecord.findFirst({
|
||||||
where: {
|
where: {
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
|
|||||||
@@ -302,11 +302,11 @@ export default function AppDashboardLayout({ children }: { children: React.React
|
|||||||
// TODO: thay thế bằng dữ liệu thực từ /analytics/districts khi API sẵn sàng (TEC-3047)
|
// TODO: thay thế bằng dữ liệu thực từ /analytics/districts khi API sẵn sàng (TEC-3047)
|
||||||
const tickerItems: TickerItem[] = [
|
const tickerItems: TickerItem[] = [
|
||||||
{ id: 'q1', label: 'Quận 1', changePercent: 2.4, direction: 'up' },
|
{ id: 'q1', label: 'Quận 1', changePercent: 2.4, direction: 'up' },
|
||||||
{ id: 'q2', label: 'Quận 2', changePercent: -0.8, direction: 'down' },
|
{ id: 'q2', label: 'Thành phố Thủ Đức', changePercent: -0.8, direction: 'down' },
|
||||||
{ id: 'q3', label: 'Quận 3', changePercent: 1.1, direction: 'up' },
|
{ id: 'q3', label: 'Quận 3', changePercent: 1.1, direction: 'up' },
|
||||||
{ id: 'q7', label: 'Quận 7', changePercent: 3.2, direction: 'up' },
|
{ id: 'q7', label: 'Quận 7', changePercent: 3.2, direction: 'up' },
|
||||||
{ id: 'binhthanh', label: 'Bình Thạnh', changePercent: 0.0, direction: 'neutral' },
|
{ id: 'binhthanh', label: 'Bình Thạnh', changePercent: 0.0, direction: 'neutral' },
|
||||||
{ id: 'thuduc', label: 'Thủ Đức', changePercent: 1.7, direction: 'up' },
|
{ id: 'thuduc', label: 'Thành phố Thủ Đức', changePercent: 1.7, direction: 'up' },
|
||||||
{ id: 'tanbinh', label: 'Tân Bình', changePercent: -1.3, direction: 'down' },
|
{ id: 'tanbinh', label: 'Tân Bình', changePercent: -1.3, direction: 'down' },
|
||||||
{ id: 'phuninh', label: 'Phú Nhuận', changePercent: 0.5, direction: 'up' },
|
{ id: 'phuninh', label: 'Phú Nhuận', changePercent: 0.5, direction: 'up' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { useGenerateReport } from '@/lib/hooks/use-reports';
|
import { useGenerateReport } from '@/lib/hooks/use-reports';
|
||||||
import type { ReportType } from '@/lib/reports-api';
|
import type { ReportType } from '@/lib/reports-api';
|
||||||
|
import { HCM_DISTRICTS } from '@/lib/vietnam-geo';
|
||||||
|
|
||||||
// ─── Constants ─────────────────────────────────────────
|
// ─── Constants ─────────────────────────────────────────
|
||||||
|
|
||||||
@@ -18,12 +19,7 @@ const PROVINCES = [
|
|||||||
'Hưng Yên', 'Quảng Ninh', 'Thái Nguyên', 'Vĩnh Phúc', 'Cần Thơ',
|
'Hưng Yên', 'Quảng Ninh', 'Thái Nguyên', 'Vĩnh Phúc', 'Cần Thơ',
|
||||||
];
|
];
|
||||||
|
|
||||||
const HCM_DISTRICTS = [
|
const HCM_DISTRICTS_LIST = HCM_DISTRICTS;
|
||||||
'Quận 1', 'Quận 3', 'Quận 4', 'Quận 5', 'Quận 6', 'Quận 7',
|
|
||||||
'Quận 8', 'Quận 10', 'Quận 11', 'Quận 12', 'Bình Thạnh',
|
|
||||||
'Gò Vấp', 'Phú Nhuận', 'Tân Bình', 'Tân Phú', 'Thủ Đức',
|
|
||||||
'Bình Tân', 'Nhà Bè', 'Hóc Môn', 'Củ Chi', 'Cần Giờ',
|
|
||||||
];
|
|
||||||
|
|
||||||
const PROPERTY_TYPES = [
|
const PROPERTY_TYPES = [
|
||||||
{ value: 'APARTMENT', label: 'Căn hộ' },
|
{ value: 'APARTMENT', label: 'Căn hộ' },
|
||||||
@@ -248,7 +244,7 @@ export default function TaoMoiPage() {
|
|||||||
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
|
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
<option value="">Chọn quận/huyện</option>
|
<option value="">Chọn quận/huyện</option>
|
||||||
{HCM_DISTRICTS.map((d) => (
|
{HCM_DISTRICTS_LIST.map((d) => (
|
||||||
<option key={d} value={d}>{d}</option>
|
<option key={d} value={d}>{d}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -302,7 +298,7 @@ export default function TaoMoiPage() {
|
|||||||
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
|
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
<option value="">Chọn quận/huyện</option>
|
<option value="">Chọn quận/huyện</option>
|
||||||
{HCM_DISTRICTS.map((d) => (
|
{HCM_DISTRICTS_LIST.map((d) => (
|
||||||
<option key={d} value={d}>{d}</option>
|
<option key={d} value={d}>{d}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -14,15 +14,10 @@ import {
|
|||||||
STATUS_LABELS,
|
STATUS_LABELS,
|
||||||
} from '@/lib/chuyen-nhuong-api';
|
} from '@/lib/chuyen-nhuong-api';
|
||||||
import { useTransferListingsSearch } from '@/lib/hooks/use-chuyen-nhuong';
|
import { useTransferListingsSearch } from '@/lib/hooks/use-chuyen-nhuong';
|
||||||
|
import { HCM_DISTRICTS } from '@/lib/vietnam-geo';
|
||||||
|
|
||||||
const PAGE_SIZE = 12;
|
const PAGE_SIZE = 12;
|
||||||
|
|
||||||
const DISTRICTS = [
|
|
||||||
'Quận 1', 'Quận 2', 'Quận 3', 'Quận 4', 'Quận 5', 'Quận 6', 'Quận 7',
|
|
||||||
'Quận 8', 'Quận 9', 'Quận 10', 'Quận 11', 'Quận 12', 'Bình Thạnh',
|
|
||||||
'Gò Vấp', 'Phú Nhuận', 'Tân Bình', 'Tân Phú', 'Thủ Đức',
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ChuyenNhuongPage() {
|
export default function ChuyenNhuongPage() {
|
||||||
const [filters, setFilters] = React.useState<SearchTransferListingsParams>({
|
const [filters, setFilters] = React.useState<SearchTransferListingsParams>({
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -93,7 +88,7 @@ export default function ChuyenNhuongPage() {
|
|||||||
aria-label="Quận/Huyện"
|
aria-label="Quận/Huyện"
|
||||||
>
|
>
|
||||||
<option value="">Quận/Huyện</option>
|
<option value="">Quận/Huyện</option>
|
||||||
{DISTRICTS.map((d) => (
|
{HCM_DISTRICTS.map((d) => (
|
||||||
<option key={d} value={d}>{d}</option>
|
<option key={d} value={d}>{d}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -81,11 +81,11 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
|
|||||||
|
|
||||||
const tickerItems: TickerItem[] = [
|
const tickerItems: TickerItem[] = [
|
||||||
{ id: 'q1', label: 'Quận 1', changePercent: 2.4, direction: 'up' },
|
{ id: 'q1', label: 'Quận 1', changePercent: 2.4, direction: 'up' },
|
||||||
{ id: 'q2', label: 'Quận 2', changePercent: -0.8, direction: 'down' },
|
{ id: 'q2', label: 'Thành phố Thủ Đức', changePercent: -0.8, direction: 'down' },
|
||||||
{ id: 'q3', label: 'Quận 3', changePercent: 1.1, direction: 'up' },
|
{ id: 'q3', label: 'Quận 3', changePercent: 1.1, direction: 'up' },
|
||||||
{ id: 'q7', label: 'Quận 7', changePercent: 3.2, direction: 'up' },
|
{ id: 'q7', label: 'Quận 7', changePercent: 3.2, direction: 'up' },
|
||||||
{ id: 'binhthanh', label: 'Bình Thạnh', changePercent: 0.0, direction: 'neutral' },
|
{ id: 'binhthanh', label: 'Bình Thạnh', changePercent: 0.0, direction: 'neutral' },
|
||||||
{ id: 'thuduc', label: 'Thủ Đức', changePercent: 1.7, direction: 'up' },
|
{ id: 'thuduc', label: 'Thành phố Thủ Đức', changePercent: 1.7, direction: 'up' },
|
||||||
{ id: 'tanbinhdistrict', label: 'Tân Bình', changePercent: -1.3, direction: 'down' },
|
{ id: 'tanbinhdistrict', label: 'Tân Bình', changePercent: -1.3, direction: 'down' },
|
||||||
{ id: 'phuninh', label: 'Phú Nhuận', changePercent: 0.5, direction: 'up' },
|
{ id: 'phuninh', label: 'Phú Nhuận', changePercent: 0.5, direction: 'up' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -19,17 +19,13 @@ import { formatPrice, formatPricePerM2 } from '@/lib/currency';
|
|||||||
import { useListingsSearch } from '@/lib/hooks/use-listings';
|
import { useListingsSearch } from '@/lib/hooks/use-listings';
|
||||||
import type { ListingDetail, PropertyType, TransactionType } from '@/lib/listings-api';
|
import type { ListingDetail, PropertyType, TransactionType } from '@/lib/listings-api';
|
||||||
import { PROPERTY_TYPES, TRANSACTION_TYPES } from '@/lib/validations/listings';
|
import { PROPERTY_TYPES, TRANSACTION_TYPES } from '@/lib/validations/listings';
|
||||||
|
import { HCM_DISTRICTS } from '@/lib/vietnam-geo';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Hằng số
|
// Hằng số
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const DISTRICTS = [
|
const DISTRICTS = HCM_DISTRICTS;
|
||||||
'Quận 1', 'Quận 2', 'Quận 3', 'Quận 4', 'Quận 5', 'Quận 6', 'Quận 7',
|
|
||||||
'Quận 8', 'Quận 9', 'Quận 10', 'Quận 11', 'Quận 12',
|
|
||||||
'Bình Thạnh', 'Gò Vấp', 'Phú Nhuận', 'Tân Bình', 'Tân Phú', 'Thủ Đức',
|
|
||||||
'Bình Chánh', 'Hóc Môn', 'Củ Chi', 'Nhà Bè', 'Cần Giờ',
|
|
||||||
];
|
|
||||||
|
|
||||||
const PRICE_RANGES = [
|
const PRICE_RANGES = [
|
||||||
{ label: 'Dưới 1 tỷ', min: '', max: '1000000000' },
|
{ label: 'Dưới 1 tỷ', min: '', max: '1000000000' },
|
||||||
|
|||||||
@@ -40,98 +40,6 @@ const TIER_COLORS: Record<string, string> = {
|
|||||||
ENTERPRISE: 'text-amber-600',
|
ENTERPRISE: 'text-amber-600',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Fallback data when API is unavailable */
|
|
||||||
const FALLBACK_PLANS: PlanDto[] = [
|
|
||||||
{
|
|
||||||
id: 'fallback-free',
|
|
||||||
tier: 'FREE',
|
|
||||||
name: 'Miễn phí',
|
|
||||||
priceMonthlyVND: '0',
|
|
||||||
priceYearlyVND: '0',
|
|
||||||
maxListings: 3,
|
|
||||||
maxSavedSearches: 5,
|
|
||||||
features: {
|
|
||||||
basicSearch: true,
|
|
||||||
listingPost: true,
|
|
||||||
maxPhotos: 5,
|
|
||||||
analytics: false,
|
|
||||||
prioritySupport: false,
|
|
||||||
aiValuation: false,
|
|
||||||
featuredListing: false,
|
|
||||||
},
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'fallback-agent',
|
|
||||||
tier: 'AGENT_PRO',
|
|
||||||
name: 'Agent Pro',
|
|
||||||
priceMonthlyVND: '499000',
|
|
||||||
priceYearlyVND: '4990000',
|
|
||||||
maxListings: 50,
|
|
||||||
maxSavedSearches: 30,
|
|
||||||
features: {
|
|
||||||
basicSearch: true,
|
|
||||||
listingPost: true,
|
|
||||||
maxPhotos: 30,
|
|
||||||
analytics: true,
|
|
||||||
prioritySupport: true,
|
|
||||||
aiValuation: true,
|
|
||||||
featuredListing: true,
|
|
||||||
leadManagement: true,
|
|
||||||
agentProfile: true,
|
|
||||||
},
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'fallback-investor',
|
|
||||||
tier: 'INVESTOR',
|
|
||||||
name: 'Investor',
|
|
||||||
priceMonthlyVND: '999000',
|
|
||||||
priceYearlyVND: '9990000',
|
|
||||||
maxListings: 20,
|
|
||||||
maxSavedSearches: 100,
|
|
||||||
features: {
|
|
||||||
basicSearch: true,
|
|
||||||
listingPost: true,
|
|
||||||
maxPhotos: 15,
|
|
||||||
analytics: true,
|
|
||||||
prioritySupport: true,
|
|
||||||
aiValuation: true,
|
|
||||||
featuredListing: false,
|
|
||||||
marketReports: true,
|
|
||||||
priceAlerts: true,
|
|
||||||
portfolioTracking: true,
|
|
||||||
},
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'fallback-enterprise',
|
|
||||||
tier: 'ENTERPRISE',
|
|
||||||
name: 'Enterprise',
|
|
||||||
priceMonthlyVND: '4990000',
|
|
||||||
priceYearlyVND: '49900000',
|
|
||||||
maxListings: -1,
|
|
||||||
maxSavedSearches: -1,
|
|
||||||
features: {
|
|
||||||
basicSearch: true,
|
|
||||||
listingPost: true,
|
|
||||||
maxPhotos: 100,
|
|
||||||
analytics: true,
|
|
||||||
prioritySupport: true,
|
|
||||||
aiValuation: true,
|
|
||||||
featuredListing: true,
|
|
||||||
leadManagement: true,
|
|
||||||
agentProfile: true,
|
|
||||||
marketReports: true,
|
|
||||||
priceAlerts: true,
|
|
||||||
portfolioTracking: true,
|
|
||||||
apiAccess: true,
|
|
||||||
whiteLabel: true,
|
|
||||||
dedicatedSupport: true,
|
|
||||||
},
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -209,7 +117,7 @@ export default function PricingPage() {
|
|||||||
const [checkoutPlan, setCheckoutPlan] = useState<PlanDto | null>(null);
|
const [checkoutPlan, setCheckoutPlan] = useState<PlanDto | null>(null);
|
||||||
const [checkoutOpen, setCheckoutOpen] = useState(false);
|
const [checkoutOpen, setCheckoutOpen] = useState(false);
|
||||||
|
|
||||||
const plans = (plansData ?? (error ? FALLBACK_PLANS : []))
|
const plans = (plansData ?? [])
|
||||||
.slice()
|
.slice()
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
@@ -316,6 +224,13 @@ export default function PricingPage() {
|
|||||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||||
{t('loading')}
|
{t('loading')}
|
||||||
</div>
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||||
|
<p className="text-lg font-medium text-destructive">
|
||||||
|
{t('errorLoadingPlans')}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">{t('errorLoadingPlansHint')}</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{plans.map((plan) => {
|
{plans.map((plan) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { useSwipeable } from 'react-swipeable';
|
||||||
import { ImageLightbox } from '@/components/listings/image-lightbox';
|
import { ImageLightbox } from '@/components/listings/image-lightbox';
|
||||||
import { shimmerBlurDataURL } from '@/lib/image-blur';
|
import { shimmerBlurDataURL } from '@/lib/image-blur';
|
||||||
import type { PropertyMedia } from '@/lib/listings-api';
|
import type { PropertyMedia } from '@/lib/listings-api';
|
||||||
@@ -22,6 +23,13 @@ export function ImageGallery({ media, className }: ImageGalleryProps) {
|
|||||||
setLightboxOpen(true);
|
setLightboxOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const swipeHandlers = useSwipeable({
|
||||||
|
onSwipedLeft: () => setSelectedIndex((i) => (i < images.length - 1 ? i + 1 : 0)),
|
||||||
|
onSwipedRight: () => setSelectedIndex((i) => (i > 0 ? i - 1 : images.length - 1)),
|
||||||
|
preventScrollOnSwipe: true,
|
||||||
|
trackMouse: false,
|
||||||
|
});
|
||||||
|
|
||||||
if (images.length === 0) {
|
if (images.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -38,7 +46,7 @@ export function ImageGallery({ media, className }: ImageGalleryProps) {
|
|||||||
return (
|
return (
|
||||||
<div className={cn('space-y-3', className)}>
|
<div className={cn('space-y-3', className)}>
|
||||||
{/* Main image */}
|
{/* Main image */}
|
||||||
<div className="relative aspect-video overflow-hidden rounded-lg bg-muted">
|
<div className="relative aspect-video overflow-hidden rounded-lg bg-muted" {...(images.length > 1 ? swipeHandlers : {})}>
|
||||||
<button
|
<button
|
||||||
onClick={() => openLightbox(selectedIndex)}
|
onClick={() => openLightbox(selectedIndex)}
|
||||||
className="absolute inset-0 z-10 cursor-zoom-in"
|
className="absolute inset-0 z-10 cursor-zoom-in"
|
||||||
|
|||||||
70
apps/web/lib/vietnam-geo.ts
Normal file
70
apps/web/lib/vietnam-geo.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* vietnam-geo.ts
|
||||||
|
* Centralized Vietnamese administrative geography data.
|
||||||
|
*
|
||||||
|
* NOTE: As of 01/01/2021, Quận 2, Quận 9 and the old Thủ Đức district were
|
||||||
|
* merged into Thành phố Thủ Đức. Use HCM_DISTRICTS for all district-picker UIs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Current Ho Chi Minh City districts / city-level subdivisions (post-2021). */
|
||||||
|
export const HCM_DISTRICTS: readonly string[] = [
|
||||||
|
// Inner urban districts (quận nội thành)
|
||||||
|
'Quận 1',
|
||||||
|
'Quận 3',
|
||||||
|
'Quận 4',
|
||||||
|
'Quận 5',
|
||||||
|
'Quận 6',
|
||||||
|
'Quận 7',
|
||||||
|
'Quận 8',
|
||||||
|
'Quận 10',
|
||||||
|
'Quận 11',
|
||||||
|
'Quận 12',
|
||||||
|
// Thu Duc city (merged from former Quận 2, Quận 9, and Thủ Đức district)
|
||||||
|
'Thành phố Thủ Đức',
|
||||||
|
// Suburban districts (quận ngoại thành)
|
||||||
|
'Bình Tân',
|
||||||
|
'Bình Thạnh',
|
||||||
|
'Gò Vấp',
|
||||||
|
'Phú Nhuận',
|
||||||
|
'Tân Bình',
|
||||||
|
'Tân Phú',
|
||||||
|
// Rural districts (huyện)
|
||||||
|
'Bình Chánh',
|
||||||
|
'Cần Giờ',
|
||||||
|
'Củ Chi',
|
||||||
|
'Hóc Môn',
|
||||||
|
'Nhà Bè',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Major Vietnamese provinces / centrally-administered municipalities. */
|
||||||
|
export const PROVINCES: readonly string[] = [
|
||||||
|
'Hồ Chí Minh',
|
||||||
|
'Hà Nội',
|
||||||
|
'Đà Nẵng',
|
||||||
|
'Bình Dương',
|
||||||
|
'Đồng Nai',
|
||||||
|
'Long An',
|
||||||
|
'Bà Rịa - Vũng Tàu',
|
||||||
|
'Bắc Ninh',
|
||||||
|
'Hải Phòng',
|
||||||
|
'Hải Dương',
|
||||||
|
'Hưng Yên',
|
||||||
|
'Quảng Ninh',
|
||||||
|
'Thái Nguyên',
|
||||||
|
'Vĩnh Phúc',
|
||||||
|
'Cần Thơ',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Major cities shown in city pickers across the platform. */
|
||||||
|
export const MAJOR_CITIES: readonly string[] = [
|
||||||
|
'Hồ Chí Minh',
|
||||||
|
'Hà Nội',
|
||||||
|
'Đà Nẵng',
|
||||||
|
'Nha Trang',
|
||||||
|
'Cần Thơ',
|
||||||
|
'Hải Phòng',
|
||||||
|
'Bình Dương',
|
||||||
|
'Đồng Nai',
|
||||||
|
'Long An',
|
||||||
|
'Bà Rịa - Vũng Tàu',
|
||||||
|
] as const;
|
||||||
@@ -126,7 +126,10 @@
|
|||||||
"VILLA": "Villa",
|
"VILLA": "Villa",
|
||||||
"LAND": "Land",
|
"LAND": "Land",
|
||||||
"OFFICE": "Office",
|
"OFFICE": "Office",
|
||||||
"SHOPHOUSE": "Shophouse"
|
"SHOPHOUSE": "Shophouse",
|
||||||
|
"ROOM_RENTAL": "Room Rental",
|
||||||
|
"CONDOTEL": "Condotel",
|
||||||
|
"SERVICED_APARTMENT": "Serviced Apartment"
|
||||||
},
|
},
|
||||||
"transactionTypes": {
|
"transactionTypes": {
|
||||||
"SALE": "Sale",
|
"SALE": "Sale",
|
||||||
|
|||||||
@@ -126,7 +126,10 @@
|
|||||||
"VILLA": "Biệt thự",
|
"VILLA": "Biệt thự",
|
||||||
"LAND": "Đất nền",
|
"LAND": "Đất nền",
|
||||||
"OFFICE": "Văn phòng",
|
"OFFICE": "Văn phòng",
|
||||||
"SHOPHOUSE": "Shophouse"
|
"SHOPHOUSE": "Shophouse",
|
||||||
|
"ROOM_RENTAL": "Phòng trọ",
|
||||||
|
"CONDOTEL": "Condotel",
|
||||||
|
"SERVICED_APARTMENT": "Căn hộ dịch vụ"
|
||||||
},
|
},
|
||||||
"transactionTypes": {
|
"transactionTypes": {
|
||||||
"SALE": "Bán",
|
"SALE": "Bán",
|
||||||
|
|||||||
@@ -1,8 +1,84 @@
|
|||||||
# GoodGo Platform AI — Theo Dõi Dự Án
|
# GoodGo Platform AI — Theo Dõi Dự Án
|
||||||
|
|
||||||
**Cập Nhật Lần Cuối:** 2026-04-12
|
**Cập Nhật Lần Cuối:** 2026-04-22
|
||||||
**Dự Án:** Goodgo Platform AI
|
**Dự Án:** Goodgo Platform AI
|
||||||
**Trạng Thái:** MVP Hoàn Thành — Giai Đoạn 7 (Cải Tiến Sau MVP) Wave 14 ✅ Build Xanh
|
**Trạng Thái:** GOO-2 Audit & Execution — Sprint 1 đang triển khai
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GOO-2 Lead Orchestrator Audit — Task Tracker (2026-04-22)
|
||||||
|
|
||||||
|
### Sprint 1 — Blockers + P0 Security
|
||||||
|
|
||||||
|
| Task | Tiêu đề | Ưu tiên | Owner | Trạng thái |
|
||||||
|
|------|---------|---------|-------|------------|
|
||||||
|
| GOO-3 | Fix double CSRF middleware | Critical | Backend TechLead | ✅ done |
|
||||||
|
| GOO-4 | UsageRecord atomic metering | Critical | Senior Backend Engineer | 🔄 in_progress |
|
||||||
|
| GOO-5 | Rate-limit exchange-token | Critical | Junior Backend Engineer | 🔄 in_progress |
|
||||||
|
| GOO-6 | Fix MoMo IPN URL | Critical | Middle Backend Engineer | 🔄 in_progress |
|
||||||
|
| GOO-7 | JWT validate user status | Critical | Security Engineer | 🔄 in_progress |
|
||||||
|
| GOO-8 | Encrypt SystemSetting secrets | Critical | Security Engineer | ⏳ todo |
|
||||||
|
| GOO-9 | Fix MCP status filter | Critical | Junior Backend Engineer | ⏳ todo |
|
||||||
|
| GOO-10 | Update PRODUCTION_READINESS.md | High | Doc Bot | 🔄 in_progress |
|
||||||
|
|
||||||
|
### Sprint 2 — P0 Features + Trust
|
||||||
|
|
||||||
|
| Task | Tiêu đề | Ưu tiên | Owner | Trạng thái |
|
||||||
|
|------|---------|---------|-------|------------|
|
||||||
|
| GOO-11 | Phone-OTP login | Critical | Backend TechLead | 🔄 in_progress |
|
||||||
|
| GOO-12 | legalStatus enum + badge | Critical | Senior Backend Engineer | ⏳ todo |
|
||||||
|
| GOO-13 | Vietnamese diacritic search | Critical | Backend TechLead | ⏳ todo |
|
||||||
|
| GOO-14 | Remove $queryRawUnsafe | High | Middle Backend Engineer | ⏳ todo |
|
||||||
|
| GOO-15 | Fix soft-deleted user login | High | Junior Backend Engineer | ⏳ todo (blocked by GOO-7) |
|
||||||
|
| GOO-16 | Fix obsolete districts | High | Middle Frontend Engineer | 🔄 in_progress |
|
||||||
|
| GOO-17 | ZNS template registration | High | CMO | 🚫 blocked |
|
||||||
|
|
||||||
|
### Sprint 3 — MVP Feature Completeness
|
||||||
|
|
||||||
|
| Task | Tiêu đề | Ưu tiên | Owner | Trạng thái |
|
||||||
|
|------|---------|---------|-------|------------|
|
||||||
|
| GOO-18 | Ward-level search | High | Middle Backend Engineer | ⏳ todo |
|
||||||
|
| GOO-19 | Scam/abuse report flow | High | Senior Backend Engineer | ⏳ todo |
|
||||||
|
| GOO-20 | ROOM_RENTAL property type | High | Junior Backend Engineer #2 | 🔄 in_progress |
|
||||||
|
| GOO-21 | Vietnam admin data (ĐVHCVN) | High | Database Architect | 🔄 in_progress |
|
||||||
|
| GOO-22 | Subscription plan seeding | Medium | Junior Backend Engineer #2 | ⏳ todo |
|
||||||
|
| GOO-23 | Module boundary fixes | Medium | Backend TechLead | ⏳ todo |
|
||||||
|
|
||||||
|
### Sprint 4 — Trust + Monetization
|
||||||
|
|
||||||
|
| Task | Tiêu đề | Owner | Trạng thái |
|
||||||
|
|------|---------|-------|------------|
|
||||||
|
| GOO-24 | Certificate verification | Senior Backend Engineer | ⏳ todo (blocked by GOO-12) |
|
||||||
|
| GOO-25 | Payment go-live checklist | DevOps Engineer | 🔄 in_progress |
|
||||||
|
| GOO-26 | Revenue stats fix | Middle Backend Engineer | ⏳ todo |
|
||||||
|
| GOO-27 | Float→Decimal migration | Database Architect | 🔄 in_progress |
|
||||||
|
| GOO-28 | Typesense+MinIO health | Infrastructure Engineer | 🔄 in_progress |
|
||||||
|
|
||||||
|
### Sprint 5 — UX + Performance
|
||||||
|
|
||||||
|
| Task | Tiêu đề | Owner | Trạng thái |
|
||||||
|
|------|---------|-------|------------|
|
||||||
|
| GOO-29 | Mobile swipe gallery | Middle Frontend Engineer | ⏳ todo |
|
||||||
|
| GOO-30 | Listing expiry notification | Middle Backend Engineer | ⏳ todo |
|
||||||
|
| GOO-31 | AVM circuit breaker | Infrastructure Engineer | ⏳ todo |
|
||||||
|
| GOO-34 | P2 batch fixes | Founding Engineer | 🔄 in_progress |
|
||||||
|
|
||||||
|
### Sprint 6 — Architecture + Docs
|
||||||
|
|
||||||
|
| Task | Tiêu đề | Owner | Trạng thái |
|
||||||
|
|------|---------|-------|------------|
|
||||||
|
| GOO-32 | Architecture hygiene batch | Backend TechLead | ⏳ todo |
|
||||||
|
| GOO-33 | Documentation updates | Doc Bot | 🔄 in_progress |
|
||||||
|
|
||||||
|
### Tổng kết tiến độ
|
||||||
|
- **Done:** 1/32 (3%)
|
||||||
|
- **In progress:** 13/32 (41%)
|
||||||
|
- **Todo:** 16/32 (50%)
|
||||||
|
- **Blocked:** 2/32 (6%)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lịch sử Giai Đoạn 0-7 (trước GOO-2 audit)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
85
docs/QA_TRACKER.md
Normal file
85
docs/QA_TRACKER.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# GoodGo Platform — QA Tracker
|
||||||
|
|
||||||
|
**Cập nhật lần cuối:** 2026-04-22
|
||||||
|
**Nguồn:** GOO-2 Lead Orchestrator Audit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Baseline QA Status (từ audit 2026-04-12)
|
||||||
|
|
||||||
|
| Metric | Kết quả |
|
||||||
|
|--------|---------|
|
||||||
|
| Lint (ESLint) | PASS — 0 lỗi |
|
||||||
|
| TypeScript | 7 lỗi (thiếu kiểu vitest trong web test files) |
|
||||||
|
| Unit tests | 232 files, 1454 tests — ALL PASS |
|
||||||
|
| Build | ALL 3 packages build thành công |
|
||||||
|
| E2E | Chưa chạy lại sau audit |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Blocker Findings (BƯỚC 1 Audit — cần QA sau fix)
|
||||||
|
|
||||||
|
| ID | Mô tả | Task | Trạng thái QA | Mức ảnh hưởng |
|
||||||
|
|----|-------|------|---------------|---------------|
|
||||||
|
| BLOCKER-1 | Double CSRF middleware — login/register broken in prod | GOO-3 ✅ | Cần verify | Critical |
|
||||||
|
| BLOCKER-2 | UsageRecord race condition — quota bypass | GOO-4 | Chờ fix | Critical |
|
||||||
|
| BLOCKER-3 | exchange-token no rate limit | GOO-5 | Chờ fix | Critical |
|
||||||
|
| GAP-03 | MoMo IPN URL points to frontend | GOO-6 | Chờ fix | Critical |
|
||||||
|
| A-19 | MCP search returns 0 results (status case) | GOO-9 | Chờ fix | Critical |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Findings (cần QA sau fix)
|
||||||
|
|
||||||
|
| ID | Mô tả | Task | Trạng thái QA |
|
||||||
|
|----|-------|------|---------------|
|
||||||
|
| HIGH-1 | JWT doesn't check banned users | GOO-7 | Chờ fix |
|
||||||
|
| HIGH-2 | AI API key stored plaintext | GOO-8 | Chờ fix |
|
||||||
|
| HIGH-4 | $queryRawUnsafe in project search | GOO-14 | Chờ fix |
|
||||||
|
| MED-9 | Soft-deleted users can login | GOO-15 | Chờ fix |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Plan — Sprint 1 Verification
|
||||||
|
|
||||||
|
### API Tests (curl)
|
||||||
|
- [ ] POST /auth/login without CSRF token → 200 (not 403)
|
||||||
|
- [ ] POST /auth/register without CSRF token → 200
|
||||||
|
- [ ] POST /payments/callback/vnpay without CSRF → 200
|
||||||
|
- [ ] POST /payments/callback/momo → verifies IPN reaches backend
|
||||||
|
- [ ] POST /auth/exchange-token 6x in 60s → 429 on 6th
|
||||||
|
- [ ] Login with banned user (isActive=false) → 401
|
||||||
|
- [ ] Login with soft-deleted user (deletedAt set) → 401
|
||||||
|
- [ ] 5 concurrent listing creates → quota not exceeded
|
||||||
|
- [ ] MCP property-search tool → returns ACTIVE listings
|
||||||
|
|
||||||
|
### UI Tests (Playwright)
|
||||||
|
- [ ] Login page loads without CSRF error
|
||||||
|
- [ ] Registration flow completes
|
||||||
|
- [ ] Search returns results (Vietnamese diacritics — Sprint 2)
|
||||||
|
- [ ] Admin dashboard loads for admin user, redirects for non-admin
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Plan — Sprint 2 Verification
|
||||||
|
|
||||||
|
- [ ] Phone OTP login: request → receive → verify → authenticated
|
||||||
|
- [ ] legalStatus dropdown shows enum values (not free text)
|
||||||
|
- [ ] Search "chung cu quan 7" matches "chung cư quận 7"
|
||||||
|
- [ ] District dropdown shows "Thủ Đức" (not Quận 2/9)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug Tracking
|
||||||
|
|
||||||
|
| Bug ID | Mô tả | Task liên quan | Severity | Trạng thái |
|
||||||
|
|--------|-------|----------------|----------|------------|
|
||||||
|
| (none yet) | — | — | — | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- QA sẽ chạy full regression sau khi Sprint 1 hoàn thành
|
||||||
|
- E2E tests cần Playwright config update cho new auth flows (Sprint 2)
|
||||||
|
- Performance benchmarks sẽ chạy sau Sprint 4 (revenue stats, dashboard queries)
|
||||||
49
docs/audits/CANONICAL_INDEX.md
Normal file
49
docs/audits/CANONICAL_INDEX.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Audit Files Index
|
||||||
|
|
||||||
|
**Last Updated:** 2026-04-22
|
||||||
|
|
||||||
|
This folder contains curated canonical audit reports. Temporary or duplicate audit files have been archived.
|
||||||
|
|
||||||
|
## Canonical Audit Reports
|
||||||
|
|
||||||
|
| File | Date | Purpose | Status |
|
||||||
|
|------|------|---------|--------|
|
||||||
|
| **AUDIT_INDEX.md** | 2026-04-22 | Main audit findings index | Current |
|
||||||
|
| **AUDIT_SUMMARY.md** | 2026-04-22 | Executive audit summary | Current |
|
||||||
|
| **COMPREHENSIVE_AUDIT_2026-04-12.md** | 2026-04-12 | Full CEO audit report (8 parts) | Reference |
|
||||||
|
| **API_AUDIT_REPORT.md** | 2026-04-19 | Backend API audit (handlers, errors) | Current |
|
||||||
|
| **WEB_AUDIT_SUMMARY.md** | 2026-04-19 | Frontend Web audit (components, tests) | Current |
|
||||||
|
| **TEST_COVERAGE_AUDIT.md** | 2026-04-19 | Unit test coverage analysis | Current |
|
||||||
|
| **INFRASTRUCTURE_RUNBOOK.md** | 2026-04-19 | Infrastructure setup & troubleshooting | Current |
|
||||||
|
| **MCP_QUICK_REFERENCE.md** | 2026-04-19 | MCP servers documentation | Current |
|
||||||
|
| **CODE_QUALITY_AUDIT.md** | 2026-04-19 | Code quality metrics & patterns | Current |
|
||||||
|
| **AUDIT_DETAILED_CHECKLIST.md** | 2026-04-19 | Detailed verification checklist | Reference |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Use These Reports
|
||||||
|
|
||||||
|
### For New Team Members
|
||||||
|
1. Start with **AUDIT_SUMMARY.md** (5 min read)
|
||||||
|
2. Then **WEB_AUDIT_SUMMARY.md** + **API_AUDIT_REPORT.md** (10 min each)
|
||||||
|
3. Reference **INFRASTRUCTURE_RUNBOOK.md** for setup
|
||||||
|
|
||||||
|
### For Architecture Decisions
|
||||||
|
- Read **COMPREHENSIVE_AUDIT_2026-04-12.md** (full context)
|
||||||
|
- Check **CODE_QUALITY_AUDIT.md** for patterns
|
||||||
|
|
||||||
|
### For Testing & Coverage
|
||||||
|
- **TEST_COVERAGE_AUDIT.md** — current coverage gaps
|
||||||
|
- **AUDIT_DETAILED_CHECKLIST.md** — what needs testing
|
||||||
|
|
||||||
|
### For DevOps & Setup
|
||||||
|
- **INFRASTRUCTURE_RUNBOOK.md** — deployment & troubleshooting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Archived Files
|
||||||
|
|
||||||
|
Old audit exploration files have been moved to `.archive/` for reference but are not actively maintained. If you need historical context, check the archive.
|
||||||
|
|
||||||
|
**Curation date:** 2026-04-22
|
||||||
|
**Curator:** Doc Bot (GOO-33)
|
||||||
355
docs/ci-cd.md
Normal file
355
docs/ci-cd.md
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
# CI/CD Pipeline
|
||||||
|
|
||||||
|
GoodGo Platform sử dụng **GitHub Actions** để tự động hóa build, test, và deployment. Pipeline tuân theo quy tắc _"build once, deploy anywhere"_ — một lần build, deploy tới nhiều môi trường.
|
||||||
|
|
||||||
|
## Pipeline Overview
|
||||||
|
|
||||||
|
Pipeline bao gồm **4 bước chính**:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Lint (ESLint)
|
||||||
|
↓
|
||||||
|
2. TypeScript Check (tsc)
|
||||||
|
↓
|
||||||
|
3. Unit Tests (Jest/Vitest)
|
||||||
|
↓
|
||||||
|
4. Build (Turborepo)
|
||||||
|
```
|
||||||
|
|
||||||
|
Mỗi bước phải **pass** trước khi tiếp tục bước tiếp theo. Nếu bất kỳ bước nào thất bại, workflow dừng ngay.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Trigger Events
|
||||||
|
|
||||||
|
Pipeline **tự động chạy** khi:
|
||||||
|
|
||||||
|
| Event | Branches | Hành động |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| **Push** | `main`, `master`, `develop` | Chạy đầy đủ pipeline (lint → typecheck → test → build) |
|
||||||
|
| **Pull Request** | Bất kỳ branch | Chạy đầy đủ pipeline trước khi merge |
|
||||||
|
| **Manual** (workflow_dispatch) | Bất kỳ branch | Cho phép chạy thủ công từ GitHub UI |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1: Lint (ESLint)
|
||||||
|
|
||||||
|
**File:** `.github/workflows/ci.yml` → job `lint`
|
||||||
|
|
||||||
|
**Mục đích:** Kiểm tra style code, conventions, và phát hiện anti-patterns.
|
||||||
|
|
||||||
|
**Command:**
|
||||||
|
```bash
|
||||||
|
pnpm lint
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chi tiết:**
|
||||||
|
- ESLint config: `.eslintrc.json` (root)
|
||||||
|
- Rules: import order, naming, unused variables, etc.
|
||||||
|
- Auto-fixable issues: `pnpm lint --fix`
|
||||||
|
- **Non-auto-fixable issues:** workflow fails, developer phải fix thủ công
|
||||||
|
|
||||||
|
**Quy tắc chính:**
|
||||||
|
- ✅ Imports ordered: `external` → `internal` (path alias) → `relative`
|
||||||
|
- ✅ Không dùng `any` type (TypeScript strict)
|
||||||
|
- ✅ Không có unused variables hoặc imports
|
||||||
|
- ❌ Không mix `require()` và `import` (dùng `import` for ES6)
|
||||||
|
|
||||||
|
**Thời gian:** ~30 giây
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2: TypeScript Check (tsc)
|
||||||
|
|
||||||
|
**File:** `.github/workflows/ci.yml` → job `typecheck`
|
||||||
|
|
||||||
|
**Mục đích:** Kiểm tra type safety toàn codebase mà không compile.
|
||||||
|
|
||||||
|
**Command:**
|
||||||
|
```bash
|
||||||
|
pnpm typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chi tiết:**
|
||||||
|
- Chạy `tsc --noEmit` trên mỗi package (API, Web, etc.)
|
||||||
|
- **TypeScript config:** `tsconfig.json` (per-package)
|
||||||
|
- **Strict mode:** `strict: true`
|
||||||
|
- Path aliases: `@modules/*` (API), `@/*` (Web)
|
||||||
|
|
||||||
|
**Quy tắc chính:**
|
||||||
|
- ✅ Mọi function phải có explicit return types
|
||||||
|
- ✅ Mọi parameter phải có type annotation (strict)
|
||||||
|
- ✅ Generic types phải đủ specificity (không `Promise<any>`)
|
||||||
|
- ❌ Không dùng `any` ngoại lệ (có comment `@ts-expect-error`)
|
||||||
|
|
||||||
|
**Thời gian:** ~45 giây
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3: Unit Tests (Jest/Vitest)
|
||||||
|
|
||||||
|
**File:** `.github/workflows/ci.yml` → job `test`
|
||||||
|
|
||||||
|
**Mục đích:** Chạy suite kiểm thử đơn vị, đảm bảo logic ngữ nghĩa đúng.
|
||||||
|
|
||||||
|
**Command:**
|
||||||
|
```bash
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chi tiết:**
|
||||||
|
- **API (NestJS):** Jest + `@nestjs/testing`
|
||||||
|
- **Web (Next.js):** Vitest + `@testing-library/react`
|
||||||
|
- Coverage threshold: API ≥ 60%, Web ≥ 50%
|
||||||
|
- Snapshot tests: must commit `.snap` file changes
|
||||||
|
|
||||||
|
**Test Locations:**
|
||||||
|
| Package | Pattern | Runner |
|
||||||
|
|---------|---------|--------|
|
||||||
|
| API | `apps/api/src/**/*.spec.ts` | Jest |
|
||||||
|
| Web | `apps/web/src/**/*.spec.ts` | Vitest |
|
||||||
|
| Libs | `libs/**/*.spec.ts` | Jest (libs) / Vitest (web libs) |
|
||||||
|
|
||||||
|
**Quy tắc viết Test:**
|
||||||
|
- ✅ Test AAA pattern: **A**rrange → **A**ct → **A**ssert
|
||||||
|
- ✅ Describe blocks: `[Unit/Integration] ClassName.method()`
|
||||||
|
- ✅ Mock external dependencies (database, HTTP, etc.)
|
||||||
|
- ✅ Test happy path + error cases + edge cases
|
||||||
|
- ❌ Không mock internal logic
|
||||||
|
- ❌ Không snapshot-test component trees (snapshot brittle)
|
||||||
|
|
||||||
|
**Thời gian:** ~60 giây (API), ~30 giây (Web)
|
||||||
|
|
||||||
|
**Xem coverage:**
|
||||||
|
```bash
|
||||||
|
pnpm test -- --coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4: Build (Turborepo)
|
||||||
|
|
||||||
|
**File:** `.github/workflows/ci.yml` → job `build`
|
||||||
|
|
||||||
|
**Mục đích:** Production build tất cả packages, detect breaking changes.
|
||||||
|
|
||||||
|
**Command:**
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chi tiết:**
|
||||||
|
- **Turborepo caching:** Bỏ qua build lại nếu code không thay đổi
|
||||||
|
- **Targets:** `apps/api`, `apps/web`, `libs/ai-services`, `libs/mcp-servers`
|
||||||
|
- **Output:** `dist/` per-package
|
||||||
|
- Vô hiệu hóa cache: `--no-cache`
|
||||||
|
|
||||||
|
**Per-Package Build:**
|
||||||
|
| Package | Command | Output | Notes |
|
||||||
|
|---------|---------|--------|-------|
|
||||||
|
| API | `nest build` | `dist/` | Production NestJS app |
|
||||||
|
| Web | `next build` | `.next/` | Optimized Next.js app |
|
||||||
|
| AI Services | `docker build` | `Dockerfile` | Python FastAPI image (không build trong CI, chỉ lint + test) |
|
||||||
|
| MCP Servers | Bundled vào API | N/A | TypeScript → JS |
|
||||||
|
|
||||||
|
**Thời gian:** ~120 giây (with Turborepo cache)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling & Retry
|
||||||
|
|
||||||
|
### CI Pipeline Retry Policy
|
||||||
|
|
||||||
|
| Failure | Action | Retry |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| Network timeout | Auto-retry (3x) | Yes, within same run |
|
||||||
|
| Out of disk space | Fail + notify | Manual re-run |
|
||||||
|
| Flaky test | Fail (capture logs) | Manual re-run w/ `--seed` |
|
||||||
|
| Git checkout error | Fail + notify | Manual re-run |
|
||||||
|
|
||||||
|
### Local Debugging
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Replicate exact CI environment locally
|
||||||
|
docker pull ghcr.io/actions/runner:latest
|
||||||
|
|
||||||
|
# Or run pnpm commands locally
|
||||||
|
pnpm install
|
||||||
|
pnpm lint --debug
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm test -- --bail # stop on first failure
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GitHub Status Checks
|
||||||
|
|
||||||
|
Pull requests require **all 4 checks to pass** before merging:
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ lint
|
||||||
|
✅ typecheck
|
||||||
|
✅ test
|
||||||
|
✅ build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Branch Protection Rules:**
|
||||||
|
|
||||||
|
- ✅ Require PR reviews: 1 approval minimum (code owner)
|
||||||
|
- ✅ Dismiss stale PR approvals
|
||||||
|
- ✅ Require status checks to pass
|
||||||
|
- ✅ Require branches to be up to date before merging
|
||||||
|
- ✅ Restrict who can push to matching branches (admin only)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Secrets & Environment Variables
|
||||||
|
|
||||||
|
### CI-Only Secrets (`.github/secrets/`)
|
||||||
|
|
||||||
|
| Secret | Used In | Purpose |
|
||||||
|
|--------|---------|---------|
|
||||||
|
| `DATABASE_URL_TEST` | Step 3 (test) | Test database (PostgreSQL) |
|
||||||
|
| `REDIS_URL_TEST` | Step 3 (test) | Test Redis cache |
|
||||||
|
| `OPENAI_API_KEY` | Step 3 (test) | Claude API (if needed) |
|
||||||
|
|
||||||
|
### Build-Time Env (`.env.ci`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env.ci (git-tracked)
|
||||||
|
NODE_ENV=test
|
||||||
|
LOG_LEVEL=warn
|
||||||
|
DATABASE_POOL_MIN=2
|
||||||
|
DATABASE_POOL_MAX=5
|
||||||
|
REDIS_TIMEOUT=5000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deploy-Only Secrets (`.github/secrets/` for Production)
|
||||||
|
|
||||||
|
| Secret | Deploy Target | Used In |
|
||||||
|
|--------|---------------|---------|
|
||||||
|
| `DOCKER_REGISTRY_TOKEN` | Docker Hub / GitHub Container Registry | Push image after build success |
|
||||||
|
| `DEPLOY_SSH_KEY` | Production VPS | SSH into server + restart service |
|
||||||
|
| `SENTRY_AUTH_TOKEN` | Sentry.io | Release tracking |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment Strategy
|
||||||
|
|
||||||
|
### Auto-Deploy Trigger
|
||||||
|
|
||||||
|
Push to `main` → All checks pass → Auto-deploy to **Staging**
|
||||||
|
|
||||||
|
```
|
||||||
|
main branch
|
||||||
|
↓ (all checks pass)
|
||||||
|
↓
|
||||||
|
Auto-build Docker image
|
||||||
|
↓
|
||||||
|
Push to GitHub Container Registry (ghcr.io)
|
||||||
|
↓
|
||||||
|
SSH into staging server
|
||||||
|
↓
|
||||||
|
docker pull + docker-compose up -d
|
||||||
|
↓
|
||||||
|
Health checks (/health/ready) → smoke tests
|
||||||
|
↓
|
||||||
|
✅ Deployed to staging
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Deploy to Production
|
||||||
|
|
||||||
|
**Only via GitHub Release tag:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag -a v1.5.0 -m "Release 1.5.0"
|
||||||
|
git push origin v1.5.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Tag push triggers **manual approval** in GitHub → Deploy to Production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monitoring & Logs
|
||||||
|
|
||||||
|
### CI Logs
|
||||||
|
|
||||||
|
- **GitHub:** https://github.com/hongochai10/goodgo-bds-platform-ai/actions
|
||||||
|
- **Per-job logs:** Click workflow run → view step output
|
||||||
|
- **Artifact download:** Logs, coverage reports, etc.
|
||||||
|
|
||||||
|
### Production Deployment Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH into production server
|
||||||
|
ssh deploy@prod.goodgo.app
|
||||||
|
|
||||||
|
# View Docker Compose logs
|
||||||
|
docker-compose -f docker-compose.prod.yml logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Alerts
|
||||||
|
|
||||||
|
- **Slack:** Integration notify `#deployments` on failure
|
||||||
|
- **Email:** GitHub notifications to team@goodgo.app
|
||||||
|
- **Sentry:** Auto-capture runtime errors after deploy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Tips
|
||||||
|
|
||||||
|
### Faster Local Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lint & typecheck only (skip test & build for quick feedback)
|
||||||
|
pnpm lint && pnpm typecheck
|
||||||
|
|
||||||
|
# Test only changed files
|
||||||
|
pnpm test -- --onlyChanged
|
||||||
|
|
||||||
|
# Build only specific package
|
||||||
|
pnpm --filter api build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Faster CI (reduce time)
|
||||||
|
|
||||||
|
1. **Cache optimization:** Turborepo already caches built packages
|
||||||
|
2. **Parallel jobs:** Lint + typecheck run simultaneously (in CI yaml, set `runs-on: ubuntu-latest`)
|
||||||
|
3. **Skip full build on patch commits:** Use `[skip ci]` in commit message (not recommended for main)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common CI Failures
|
||||||
|
|
||||||
|
| Error | Cause | Fix |
|
||||||
|
|-------|-------|-----|
|
||||||
|
| `ESLint error: Import not sorted` | Import order wrong | `pnpm lint --fix` |
|
||||||
|
| `TypeScript error: Type 'any'` | Strict type checking | Add explicit type annotation |
|
||||||
|
| `Jest timeout: test took > 5000ms` | Slow test (DB, network) | Mock external calls, increase timeout `jest.setTimeout(10000)` |
|
||||||
|
| `Out of disk space` | GitHub runner full | Clear cache, reduce artifact retention |
|
||||||
|
| `pnpm install stuck` | Network issue | Retry: `rm -rf node_modules && pnpm install` |
|
||||||
|
|
||||||
|
### Re-run CI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Re-run entire workflow (GitHub UI)
|
||||||
|
Actions tab → select workflow → click "Re-run" button
|
||||||
|
|
||||||
|
# Or locally, push empty commit
|
||||||
|
git commit --allow-empty -m "Trigger CI"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- GitHub Actions: https://docs.github.com/en/actions
|
||||||
|
- Turborepo Caching: https://turbo.build/repo/docs/core-concepts/caching
|
||||||
|
- ESLint Config: `/.eslintrc.json`
|
||||||
|
- TypeScript Config: `/tsconfig.json` (root), `/apps/api/tsconfig.json`, etc.
|
||||||
|
- Jest Config: `/apps/api/jest.config.js`
|
||||||
|
- Vitest Config: `/apps/web/vitest.config.ts`
|
||||||
600
docs/mcp-servers.md
Normal file
600
docs/mcp-servers.md
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
# MCP Servers Documentation
|
||||||
|
|
||||||
|
GoodGo Platform sử dụng **Model Context Protocol (MCP)** để cấp quyền truy cập các công cụ chuyên dụng cho Claude AI. Ba MCP servers chính cung cấp khả năng **tìm kiếm bất động sản**, **phân tích thị trường**, và **định giá tự động (AVM)**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
| Server | Port | Tools | Purpose |
|
||||||
|
|--------|------|-------|---------|
|
||||||
|
| **Property Search** | 3001 | `search_properties`, `compare_properties`, `get_property_details` | Tìm kiếm bất động sản, so sánh, chi tiết |
|
||||||
|
| **Market Analytics** | 3001 | `get_market_report`, `analyze_trends`, `get_price_indices` | Phân tích thị trường, xu hướng giá |
|
||||||
|
| **Valuation** | 3001 | `estimate_valuation`, `extract_features`, `compare_valuations` | Định giá BĐS, so sánh XGBoost |
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
- All MCP servers are **in-process** (không tách microservice cho MVP)
|
||||||
|
- Chạy cùng process NestJS API
|
||||||
|
- HTTP transport dùng `/mcp/tools/*` endpoint
|
||||||
|
- Claude API calls `POST /mcp/tools/{toolName}` để invoke tools
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Property Search Server
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Cung cấp công cụ tìm kiếm bất động sản với:
|
||||||
|
- Full-text search (Typesense)
|
||||||
|
- Geo-spatial search (PostGIS)
|
||||||
|
- Faceted filtering (giá, loại, phòng ngủ, quận huyện)
|
||||||
|
|
||||||
|
### Tools
|
||||||
|
|
||||||
|
#### `search_properties`
|
||||||
|
|
||||||
|
Tìm kiếm bất động sản với nhiều tiêu chí.
|
||||||
|
|
||||||
|
**Input Schema:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"query": "chung cư quận 1",
|
||||||
|
"filters": {
|
||||||
|
"priceMin": 1000000000,
|
||||||
|
"priceMax": 5000000000,
|
||||||
|
"bedrooms": 2,
|
||||||
|
"district": "Quận 1",
|
||||||
|
"propertyType": "APARTMENT"
|
||||||
|
},
|
||||||
|
"sort": "price_asc",
|
||||||
|
"limit": 10,
|
||||||
|
"offset": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
| Param | Type | Required | Default | Notes |
|
||||||
|
|-------|------|----------|---------|-------|
|
||||||
|
| `query` | string | Yes | - | Tìm kiếm từ khóa (tiêu đề, mô tả) |
|
||||||
|
| `filters.priceMin` | number | No | 0 | Giá tối thiểu (VND) |
|
||||||
|
| `filters.priceMax` | number | No | ∞ | Giá tối đa (VND) |
|
||||||
|
| `filters.bedrooms` | number | No | - | Số phòng ngủ |
|
||||||
|
| `filters.district` | string | No | - | Quận huyện |
|
||||||
|
| `filters.propertyType` | enum | No | - | APARTMENT, HOUSE, LAND, ROOM_RENTAL |
|
||||||
|
| `sort` | enum | No | `relevance` | `price_asc`, `price_desc`, `newest`, `relevance` |
|
||||||
|
| `limit` | number | No | 20 | Số kết quả (1-100) |
|
||||||
|
| `offset` | number | No | 0 | Phân trang offset |
|
||||||
|
|
||||||
|
**Output Example:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total": 245,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "prop-001",
|
||||||
|
"title": "Căn hộ 2PN view sông Sài Gòn",
|
||||||
|
"type": "APARTMENT",
|
||||||
|
"price": 3500000000,
|
||||||
|
"bedrooms": 2,
|
||||||
|
"bathrooms": 2,
|
||||||
|
"area": 85,
|
||||||
|
"district": "Quận 1",
|
||||||
|
"ward": "Phường Bến Nghé",
|
||||||
|
"address": "123 Tôn Đức Thắng, Q.1",
|
||||||
|
"agentName": "Nguyễn Văn A",
|
||||||
|
"agentPhone": "0987654321",
|
||||||
|
"status": "ACTIVE",
|
||||||
|
"createdAt": "2026-04-20T10:30:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"facets": {
|
||||||
|
"districts": [
|
||||||
|
{ "name": "Quận 1", "count": 42 },
|
||||||
|
{ "name": "Quận 3", "count": 38 }
|
||||||
|
],
|
||||||
|
"propertyTypes": [
|
||||||
|
{ "name": "APARTMENT", "count": 120 },
|
||||||
|
{ "name": "HOUSE", "count": 85 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `compare_properties`
|
||||||
|
|
||||||
|
So sánh 2-5 bất động sản dựa trên giá, diện tích, vị trí, etc.
|
||||||
|
|
||||||
|
**Input Schema:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"propertyIds": ["prop-001", "prop-002", "prop-003"],
|
||||||
|
"metrics": ["price", "area", "price_per_sqm", "location_score", "agent_rating"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output Example:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"comparison": [
|
||||||
|
{
|
||||||
|
"propertyId": "prop-001",
|
||||||
|
"title": "Căn hộ 2PN Q.1",
|
||||||
|
"price": 3500000000,
|
||||||
|
"area": 85,
|
||||||
|
"price_per_sqm": 41176470,
|
||||||
|
"location_score": 4.8,
|
||||||
|
"agent_rating": 4.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"propertyId": "prop-002",
|
||||||
|
"title": "Căn hộ 2PN Q.3",
|
||||||
|
"price": 2800000000,
|
||||||
|
"area": 78,
|
||||||
|
"price_per_sqm": 35897436,
|
||||||
|
"location_score": 4.3,
|
||||||
|
"agent_rating": 4.2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recommendation": "prop-002 có giá tốt hơn với vị trí tương đương"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `get_property_details`
|
||||||
|
|
||||||
|
Lấy chi tiết đầy đủ của một bất động sản.
|
||||||
|
|
||||||
|
**Input Schema:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"propertyId": "prop-001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output Example:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "prop-001",
|
||||||
|
"title": "Căn hộ 2PN view sông Sài Gòn",
|
||||||
|
"description": "Căn hộ cao cấp tại trung tâm Q.1...",
|
||||||
|
"type": "APARTMENT",
|
||||||
|
"price": 3500000000,
|
||||||
|
"bedrooms": 2,
|
||||||
|
"bathrooms": 2,
|
||||||
|
"area": 85,
|
||||||
|
"district": "Quận 1",
|
||||||
|
"ward": "Phường Bến Nghé",
|
||||||
|
"address": "123 Tôn Đức Thắng, Q.1",
|
||||||
|
"coordinates": {
|
||||||
|
"latitude": 10.7769,
|
||||||
|
"longitude": 106.7009
|
||||||
|
},
|
||||||
|
"features": ["view sông", "ban công rộng", "tầng cao", "gần metro"],
|
||||||
|
"agent": {
|
||||||
|
"id": "agent-001",
|
||||||
|
"name": "Nguyễn Văn A",
|
||||||
|
"phone": "0987654321",
|
||||||
|
"rating": 4.5,
|
||||||
|
"reviews": 47
|
||||||
|
},
|
||||||
|
"media": [
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"url": "https://storage.goodgo.app/prop-001/img-1.jpg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"status": "ACTIVE",
|
||||||
|
"createdAt": "2026-04-20T10:30:00Z",
|
||||||
|
"reviews": [
|
||||||
|
{
|
||||||
|
"author": "Trần Văn B",
|
||||||
|
"rating": 5,
|
||||||
|
"text": "Căn hộ đẹp, vị trí tuyệt vời"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Market Analytics Server
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Cung cấp dữ liệu thị trường bất động sản:
|
||||||
|
- Báo cáo giá theo quận huyện
|
||||||
|
- Phân tích xu hướng giá theo thời gian
|
||||||
|
- Chỉ số thị trường
|
||||||
|
|
||||||
|
### Tools
|
||||||
|
|
||||||
|
#### `get_market_report`
|
||||||
|
|
||||||
|
Lấy báo cáo thị trường theo quận huyện.
|
||||||
|
|
||||||
|
**Input Schema:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"district": "Quận 1",
|
||||||
|
"propertyType": "APARTMENT",
|
||||||
|
"period": "monthly"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output Example:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"district": "Quận 1",
|
||||||
|
"propertyType": "APARTMENT",
|
||||||
|
"period": "April 2026",
|
||||||
|
"statistics": {
|
||||||
|
"averagePrice": 3200000000,
|
||||||
|
"medianPrice": 3100000000,
|
||||||
|
"priceMin": 800000000,
|
||||||
|
"priceMax": 8500000000,
|
||||||
|
"averageArea": 82,
|
||||||
|
"averagePricePerSqm": 39024390,
|
||||||
|
"activeListings": 342,
|
||||||
|
"soldListings": 28,
|
||||||
|
"rentedListings": 15
|
||||||
|
},
|
||||||
|
"trends": {
|
||||||
|
"priceChangePercent": 2.5,
|
||||||
|
"priceChangeVsLastMonth": "Tăng 2.5%",
|
||||||
|
"velocityDays": 15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `analyze_trends`
|
||||||
|
|
||||||
|
Phân tích xu hướng giá theo thời gian.
|
||||||
|
|
||||||
|
**Input Schema:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"district": "Quận 1",
|
||||||
|
"propertyType": "APARTMENT",
|
||||||
|
"months": 12
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output Example:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"district": "Quận 1",
|
||||||
|
"propertyType": "APARTMENT",
|
||||||
|
"trend": [
|
||||||
|
{
|
||||||
|
"month": "May 2025",
|
||||||
|
"averagePrice": 2900000000,
|
||||||
|
"medianPrice": 2800000000,
|
||||||
|
"activeListings": 250
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"month": "April 2026",
|
||||||
|
"averagePrice": 3200000000,
|
||||||
|
"medianPrice": 3100000000,
|
||||||
|
"activeListings": 342
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast": {
|
||||||
|
"nextMonthPredicted": 3280000000,
|
||||||
|
"confidence": 0.75,
|
||||||
|
"direction": "up"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `get_price_indices`
|
||||||
|
|
||||||
|
Lấy chỉ số giá toàn thị trường (bình thường hóa = 100).
|
||||||
|
|
||||||
|
**Input Schema:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"baseMonth": "January 2025"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output Example:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"baseIndex": 100,
|
||||||
|
"baseMonth": "January 2025",
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"month": "January 2025",
|
||||||
|
"indexValue": 100,
|
||||||
|
"growth": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"month": "April 2026",
|
||||||
|
"indexValue": 110.5,
|
||||||
|
"growth": 10.5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"byDistrict": {
|
||||||
|
"Quận 1": 112.3,
|
||||||
|
"Quận 3": 108.7,
|
||||||
|
"Thủ Đức": 105.2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Valuation Server
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Định giá tự động bất động sản dùng mô hình **XGBoost**:
|
||||||
|
- Estimate valuation based on features
|
||||||
|
- Extract features from description
|
||||||
|
- Compare valuations across similar properties
|
||||||
|
|
||||||
|
### Tools
|
||||||
|
|
||||||
|
#### `estimate_valuation`
|
||||||
|
|
||||||
|
Ước lượng giá bất động sản dựa trên đặc trưng.
|
||||||
|
|
||||||
|
**Input Schema:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"district": "Quận 1",
|
||||||
|
"propertyType": "APARTMENT",
|
||||||
|
"area": 85,
|
||||||
|
"bedrooms": 2,
|
||||||
|
"bathrooms": 2,
|
||||||
|
"features": ["view sông", "ban công", "tầng cao", "gần metro"],
|
||||||
|
"yearBuilt": 2015,
|
||||||
|
"location": {
|
||||||
|
"latitude": 10.7769,
|
||||||
|
"longitude": 106.7009
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output Example:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"estimatedPrice": 3250000000,
|
||||||
|
"priceRange": {
|
||||||
|
"low": 2850000000,
|
||||||
|
"high": 3650000000
|
||||||
|
},
|
||||||
|
"confidence": 0.82,
|
||||||
|
"factors": {
|
||||||
|
"area": "Positive (85 sqm)",
|
||||||
|
"location": "High demand (Q.1)",
|
||||||
|
"features": "Premium (view sông, gần metro)",
|
||||||
|
"yearBuilt": "Neutral (11 years old)"
|
||||||
|
},
|
||||||
|
"comparables": [
|
||||||
|
{
|
||||||
|
"id": "prop-001",
|
||||||
|
"actualPrice": 3200000000,
|
||||||
|
"similarity": 0.89
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `extract_features`
|
||||||
|
|
||||||
|
Trích xuất đặc trưng từ mô tả bất động sản (NLP).
|
||||||
|
|
||||||
|
**Input Schema:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"description": "Căn hộ cao cấp 2 phòng ngủ, 2 phòng tắm, view sông Sài Gòn, ban công rộng, gần trạm metro, tầng 15, xây năm 2015...",
|
||||||
|
"title": "Căn hộ 2PN view sông Sài Gòn"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output Example:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"extracted": {
|
||||||
|
"bedrooms": 2,
|
||||||
|
"bathrooms": 2,
|
||||||
|
"area": null,
|
||||||
|
"features": ["view sông", "ban công", "gần metro", "tầng cao"],
|
||||||
|
"yearBuilt": 2015,
|
||||||
|
"condition": "tốt"
|
||||||
|
},
|
||||||
|
"confidence": {
|
||||||
|
"bedrooms": 0.98,
|
||||||
|
"features": 0.85,
|
||||||
|
"yearBuilt": 0.92
|
||||||
|
},
|
||||||
|
"uncertainties": [
|
||||||
|
"area not mentioned",
|
||||||
|
"exact floor number not in description"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `compare_valuations`
|
||||||
|
|
||||||
|
So sánh định giá của các bất động sản tương tự.
|
||||||
|
|
||||||
|
**Input Schema:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"referencePropertyId": "prop-001",
|
||||||
|
"candidatePropertyIds": ["prop-002", "prop-003", "prop-004"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output Example:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reference": {
|
||||||
|
"propertyId": "prop-001",
|
||||||
|
"title": "Căn hộ 2PN Q.1",
|
||||||
|
"actualPrice": 3500000000,
|
||||||
|
"estimatedPrice": 3250000000
|
||||||
|
},
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"propertyId": "prop-002",
|
||||||
|
"title": "Căn hộ 2PN Q.3",
|
||||||
|
"actualPrice": 2800000000,
|
||||||
|
"estimatedPrice": 2950000000,
|
||||||
|
"overUndervalued": "Undervalued by 5.1%",
|
||||||
|
"similarity": 0.76
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recommendation": "prop-002 is a good value compared to reference property"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/api/src/
|
||||||
|
├── modules/
|
||||||
|
│ └── mcp/
|
||||||
|
│ ├── mcp.module.ts # Module definition
|
||||||
|
│ ├── mcp.controller.ts # HTTP endpoint: POST /mcp/tools/:toolName
|
||||||
|
│ ├── mcp-registry.service.ts # Registry: tool name → handler function
|
||||||
|
│ ├── servers/
|
||||||
|
│ │ ├── property-search.server.ts
|
||||||
|
│ │ ├── market-analytics.server.ts
|
||||||
|
│ │ └── valuation.server.ts
|
||||||
|
│ └── dto/
|
||||||
|
│ ├── search-properties.dto.ts
|
||||||
|
│ ├── market-report.dto.ts
|
||||||
|
│ └── estimate-valuation.dto.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### McpRegistryService
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Register tools
|
||||||
|
export class McpRegistryService {
|
||||||
|
private tools = new Map<string, Tool>();
|
||||||
|
|
||||||
|
register(name: string, tool: Tool) {
|
||||||
|
this.tools.set(name, tool);
|
||||||
|
}
|
||||||
|
|
||||||
|
invoke(name: string, input: any): Promise<any> {
|
||||||
|
const tool = this.tools.get(name);
|
||||||
|
if (!tool) throw new NotFoundException(`Tool ${name} not found`);
|
||||||
|
return tool.execute(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTTP Endpoint
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Post('/mcp/tools/:toolName')
|
||||||
|
@Auth() // JWT required
|
||||||
|
async invokeTool(
|
||||||
|
@Param('toolName') toolName: string,
|
||||||
|
@Body() input: any
|
||||||
|
) {
|
||||||
|
return this.mcpRegistry.invoke(toolName, input);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tool Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Tool {
|
||||||
|
name: string;
|
||||||
|
schema: JsonSchema; // Input validation
|
||||||
|
execute(input: any): Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SearchPropertiesTool implements Tool {
|
||||||
|
name = 'search_properties';
|
||||||
|
schema = { /* JSON Schema */ };
|
||||||
|
|
||||||
|
execute(input: SearchPropertiesInput): Promise<any> {
|
||||||
|
// Implementation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
All MCP tools return consistent error format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "INVALID_INPUT",
|
||||||
|
"message": "District 'Quận XYZ' not found",
|
||||||
|
"details": {
|
||||||
|
"field": "district",
|
||||||
|
"value": "Quận XYZ"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### Caching
|
||||||
|
|
||||||
|
- Market Analytics: Cache reports for 1 hour
|
||||||
|
- Property Search: Cache facets, invalidate on new listing
|
||||||
|
- Valuation: Cache model predictions for 24 hours
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
- Default: 100 req/minute per user
|
||||||
|
- MCP tools: 50 req/minute per tool (stricter)
|
||||||
|
- Burst: 10 req/second
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test -- mcp/servers
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test via HTTP
|
||||||
|
curl -X POST http://localhost:3001/mcp/tools/search_properties \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"query": "chung cu quan 1", "limit": 5}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- MCP Specification: https://modelcontextprotocol.io/
|
||||||
|
- Claude API: https://anthropic.com/api
|
||||||
|
- Implementation: `apps/api/src/modules/mcp/`
|
||||||
515
docs/onboarding.md
Normal file
515
docs/onboarding.md
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
# Developer Onboarding Guide
|
||||||
|
|
||||||
|
Chào mừng đến với **GoodGo Platform** — một sàn giao dịch bất động sản Việt Nam được xây dựng trên NestJS, Next.js, PostgreSQL, và PostGIS.
|
||||||
|
|
||||||
|
Hướng dẫn này giúp bạn setup môi trường phát triển trong **< 30 phút**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites (Yêu cầu)
|
||||||
|
|
||||||
|
Trước khi bắt đầu, hãy cài đặt:
|
||||||
|
|
||||||
|
| Tool | Version | Link |
|
||||||
|
|------|---------|------|
|
||||||
|
| **Node.js** | ≥ 22.0.0 | https://nodejs.org/ |
|
||||||
|
| **pnpm** | ≥ 10.0.0 | https://pnpm.io/ |
|
||||||
|
| **PostgreSQL** | 16 + PostGIS | https://www.postgresql.org/ |
|
||||||
|
| **Docker & Docker Compose** | Latest | https://docker.com/ |
|
||||||
|
| **Git** | Latest | https://git-scm.com/ |
|
||||||
|
| **VS Code** (recommended) | Latest | https://code.visualstudio.com/ |
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Homebrew (if not already)
|
||||||
|
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
brew install node@22 pnpm postgresql@16 docker
|
||||||
|
brew install postgis # PostGIS extension
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
node --version # v22.x.x
|
||||||
|
pnpm --version # 10.x.x
|
||||||
|
psql --version # PostgreSQL 16.x
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ubuntu/Debian
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update package manager
|
||||||
|
sudo apt update
|
||||||
|
|
||||||
|
# Install Node.js 22
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||||
|
sudo apt install -y nodejs
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
npm install -g pnpm@latest
|
||||||
|
|
||||||
|
# Install PostgreSQL 16 + PostGIS
|
||||||
|
sudo apt install -y postgresql-16 postgresql-16-postgis
|
||||||
|
|
||||||
|
# Install Docker
|
||||||
|
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||||
|
sudo sh get-docker.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows (WSL2 Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Inside WSL2 Ubuntu:
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||||
|
sudo apt install -y nodejs postgresql-16 postgresql-16-postgis
|
||||||
|
npm install -g pnpm@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Clone Repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repo
|
||||||
|
git clone https://github.com/hongochai10/goodgo-bds-platform-ai.git
|
||||||
|
cd goodgo-platform-ai
|
||||||
|
|
||||||
|
# Add your SSH key to GitHub (for faster clone/push)
|
||||||
|
# https://docs.github.com/en/authentication/connecting-to-github-with-ssh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install all packages using pnpm workspaces
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# This installs:
|
||||||
|
# - Root dependencies
|
||||||
|
# - apps/api dependencies
|
||||||
|
# - apps/web dependencies
|
||||||
|
# - libs/ai-services dependencies
|
||||||
|
# - libs/mcp-servers dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected time:** ~2-5 minutes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Setup PostgreSQL (Database)
|
||||||
|
|
||||||
|
### Option A: Docker Compose (Recommended for Development)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start PostgreSQL + Redis in Docker
|
||||||
|
docker-compose -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
# Verify containers are running
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# Output should show:
|
||||||
|
# NAME STATUS
|
||||||
|
# goodgo-postgres Up 2 seconds
|
||||||
|
# goodgo-redis Up 2 seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option B: Local PostgreSQL Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create database
|
||||||
|
createdb goodgo_dev
|
||||||
|
|
||||||
|
# Enable PostGIS extension
|
||||||
|
psql goodgo_dev -c "CREATE EXTENSION IF NOT EXISTS postgis;"
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
psql goodgo_dev -c "SELECT PostGIS_version();"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Setup Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy .env.example to .env
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Edit .env with your local values
|
||||||
|
# Minimal required variables:
|
||||||
|
cat > .env << 'EOF'
|
||||||
|
NODE_ENV=development
|
||||||
|
DATABASE_URL="postgresql://postgres:password@localhost:5432/goodgo_dev"
|
||||||
|
REDIS_URL="redis://localhost:6379"
|
||||||
|
JWT_SECRET="dev-secret-change-in-production"
|
||||||
|
JWT_REFRESH_SECRET="dev-refresh-secret-change-in-production"
|
||||||
|
MAPBOX_TOKEN="your-mapbox-token-here" # Get from https://account.mapbox.com/
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# For Firebase Cloud Messaging (optional, for push notifications):
|
||||||
|
# FIREBASE_PROJECT_ID="your-project-id"
|
||||||
|
# FIREBASE_PRIVATE_KEY="..."
|
||||||
|
# FIREBASE_CLIENT_EMAIL="..."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Initialize Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate Prisma client
|
||||||
|
pnpm db:generate
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
pnpm db:migrate:dev
|
||||||
|
|
||||||
|
# Seed sample data (users, listings, districts)
|
||||||
|
pnpm db:seed
|
||||||
|
|
||||||
|
# Verify seeding
|
||||||
|
pnpm db:studio # Opens Prisma Studio GUI at http://localhost:5555
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Start Development Servers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start all services (API + Web + AI services) in watch mode
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# Output shows:
|
||||||
|
# - API running at http://localhost:3001
|
||||||
|
# - Web running at http://localhost:3000
|
||||||
|
# - Logs from both services
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected time:** ~30 seconds for startup
|
||||||
|
|
||||||
|
### Alternative: Start Individual Services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1: API only
|
||||||
|
pnpm --filter api dev
|
||||||
|
|
||||||
|
# Terminal 2: Web only
|
||||||
|
pnpm --filter web dev
|
||||||
|
|
||||||
|
# Terminal 3: AI services (Python) - optional
|
||||||
|
pnpm --filter ai-services dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Verify Setup
|
||||||
|
|
||||||
|
Open your browser and test:
|
||||||
|
|
||||||
|
### API Health Check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal or Postman
|
||||||
|
curl http://localhost:3001/health
|
||||||
|
|
||||||
|
# Should return:
|
||||||
|
# {
|
||||||
|
# "status": "ok",
|
||||||
|
# "timestamp": "2026-04-22T10:30:00Z"
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web App
|
||||||
|
|
||||||
|
Open http://localhost:3000 in your browser
|
||||||
|
|
||||||
|
- You should see the GoodGo home page
|
||||||
|
- Try registering an account (test phone: `0987654321`, any password)
|
||||||
|
|
||||||
|
### API Swagger Docs
|
||||||
|
|
||||||
|
Open http://localhost:3001/api/docs
|
||||||
|
|
||||||
|
- Interactive API documentation
|
||||||
|
- Try endpoints: GET `/api/v1/listings`, POST `/api/v1/auth/login`, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Commands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `pnpm install` | Install all dependencies |
|
||||||
|
| `pnpm dev` | Start all services in watch mode |
|
||||||
|
| `pnpm lint` | Run ESLint (check code style) |
|
||||||
|
| `pnpm lint --fix` | Auto-fix lint issues |
|
||||||
|
| `pnpm typecheck` | TypeScript type checking |
|
||||||
|
| `pnpm test` | Run unit tests |
|
||||||
|
| `pnpm test:e2e` | Run E2E tests with Playwright |
|
||||||
|
| `pnpm build` | Production build |
|
||||||
|
| `pnpm db:generate` | Generate Prisma client |
|
||||||
|
| `pnpm db:migrate:dev` | Run pending migrations |
|
||||||
|
| `pnpm db:seed` | Seed sample data |
|
||||||
|
| `pnpm db:studio` | Open Prisma Studio GUI |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
goodgo-platform-ai/
|
||||||
|
├── apps/
|
||||||
|
│ ├── api/ # NestJS backend
|
||||||
|
│ │ ├── src/
|
||||||
|
│ │ │ ├── main.ts # Entry point
|
||||||
|
│ │ │ └── modules/ # Domain modules
|
||||||
|
│ │ │ ├── auth/
|
||||||
|
│ │ │ ├── listings/
|
||||||
|
│ │ │ ├── payments/
|
||||||
|
│ │ │ └── ...
|
||||||
|
│ │ └── package.json
|
||||||
|
│ └── web/ # Next.js frontend
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── app/ # App Router pages
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ ├── lib/
|
||||||
|
│ │ └── hooks/
|
||||||
|
│ └── package.json
|
||||||
|
├── libs/
|
||||||
|
│ ├── ai-services/ # Python FastAPI (AVM, moderation)
|
||||||
|
│ └── mcp-servers/ # MCP server library
|
||||||
|
├── prisma/
|
||||||
|
│ ├── schema.prisma # Database schema
|
||||||
|
│ └── migrations/ # SQL migrations
|
||||||
|
├── docs/ # Documentation
|
||||||
|
├── .github/workflows/ # CI/CD
|
||||||
|
└── package.json (root) # pnpm workspaces config
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### 1. Create a Feature Branch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Always create a feature branch from main/master
|
||||||
|
git checkout -b feature/awesome-feature
|
||||||
|
|
||||||
|
# Branch naming convention:
|
||||||
|
# - feature/new-feature-name
|
||||||
|
# - fix/bug-description
|
||||||
|
# - refactor/code-cleanup
|
||||||
|
# - docs/documentation-update
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Make Changes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies for new package
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# Run type checking + linting
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm lint
|
||||||
|
|
||||||
|
# Run tests for your changes
|
||||||
|
pnpm test -- path/to/module
|
||||||
|
|
||||||
|
# Fix auto-fixable issues
|
||||||
|
pnpm lint --fix
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Commit & Push
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Commit following conventional commits format
|
||||||
|
git add .
|
||||||
|
git commit -m "feat(module): short description"
|
||||||
|
|
||||||
|
# Commit message format:
|
||||||
|
# feat(scope): description
|
||||||
|
# fix(scope): description
|
||||||
|
# docs(scope): description
|
||||||
|
# refactor(scope): description
|
||||||
|
# test(scope): description
|
||||||
|
# chore(scope): description
|
||||||
|
|
||||||
|
# Push branch
|
||||||
|
git push origin feature/awesome-feature
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Create Pull Request
|
||||||
|
|
||||||
|
- Go to GitHub: https://github.com/hongochai10/goodgo-bds-platform-ai/pulls
|
||||||
|
- Click **New Pull Request**
|
||||||
|
- Select your branch
|
||||||
|
- Add description (what changed, why, testing notes)
|
||||||
|
- Ensure all checks pass ✅ (lint, typecheck, test, build)
|
||||||
|
- Request review from team lead
|
||||||
|
- Merge after approval
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debugging Tips
|
||||||
|
|
||||||
|
### API Debugging
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enable debug logging
|
||||||
|
DEBUG=*:* pnpm --filter api dev
|
||||||
|
|
||||||
|
# Or in VS Code: use `.vscode/launch.json` for breakpoints
|
||||||
|
# F5 to start debugger
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web Debugging
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Chrome DevTools: F12
|
||||||
|
# React DevTools extension: https://chrome.google.com/webstore/
|
||||||
|
|
||||||
|
# Debug Next.js:
|
||||||
|
# .next/server/pages shows compiled route handlers
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Debugging
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Connect to database directly
|
||||||
|
psql $DATABASE_URL
|
||||||
|
|
||||||
|
# Common queries:
|
||||||
|
SELECT * FROM "User" LIMIT 5;
|
||||||
|
SELECT COUNT(*) FROM "Listing";
|
||||||
|
SELECT * FROM "Listing" WHERE "status" = 'ACTIVE' LIMIT 5;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Redis Debugging
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Connect to Redis CLI
|
||||||
|
redis-cli
|
||||||
|
|
||||||
|
# Check keys
|
||||||
|
KEYS *
|
||||||
|
GET session:user:123
|
||||||
|
INCR counter
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## VS Code Setup (Optional)
|
||||||
|
|
||||||
|
### Recommended Extensions
|
||||||
|
|
||||||
|
```json
|
||||||
|
// .vscode/extensions.json
|
||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"esbenp.prettier-vscode", // Code formatter
|
||||||
|
"dbaeumer.vscode-eslint", // ESLint linting
|
||||||
|
"ms-vscode.makefile-tools", // Makefile support
|
||||||
|
"ms-vscode.extension-pack-fe", // Frontend bundle
|
||||||
|
"ms-vscode-remote.remote-containers", // Docker support
|
||||||
|
"bradlc.vscode-tailwindcss", // Tailwind CSS support
|
||||||
|
"vivaxy.vscode-conventional-commits", // Commit helper
|
||||||
|
"Prisma.prisma" // Prisma schema support
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Launch Configuration (Debugging)
|
||||||
|
|
||||||
|
Create `.vscode/launch.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "NestJS Debug",
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"program": "${workspaceFolder}/node_modules/@nestjs/cli/bin/nest.js",
|
||||||
|
"args": ["start", "--debug", "--watch"],
|
||||||
|
"cwd": "${workspaceFolder}/apps/api",
|
||||||
|
"skipFiles": ["<node_internals>/**"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Problem: `pnpm install` hangs
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
rm -rf node_modules .pnpm-store pnpm-lock.yaml
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
```
|
||||||
|
|
||||||
|
### Problem: PostgreSQL connection refused
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
# Check if PostgreSQL is running
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# If not running, start it
|
||||||
|
docker-compose -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
# Or check local PostgreSQL
|
||||||
|
pg_isready -h localhost -p 5432
|
||||||
|
```
|
||||||
|
|
||||||
|
### Problem: `pnpm dev` fails with "EADDRINUSE"
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
# Port 3000 or 3001 is already in use
|
||||||
|
# Kill existing process
|
||||||
|
lsof -i :3000 # Find process ID
|
||||||
|
kill -9 <PID>
|
||||||
|
|
||||||
|
# Or use different port
|
||||||
|
PORT=3002 pnpm --filter web dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Problem: TypeScript errors in IDE but tests pass
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
# Regenerate Prisma types
|
||||||
|
pnpm db:generate
|
||||||
|
|
||||||
|
# Restart VS Code
|
||||||
|
Cmd+Shift+P → "Developer: Reload Window"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting Help
|
||||||
|
|
||||||
|
- **Slack:** `#dev` channel (if you have access)
|
||||||
|
- **GitHub Issues:** https://github.com/hongochai10/goodgo-bds-platform-ai/issues
|
||||||
|
- **Documentation:** `/docs` folder
|
||||||
|
- **Architecture:** `/docs/architecture.md`
|
||||||
|
- **API Reference:** `/docs/api-endpoints.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. ✅ **Setup complete?** Start with a small bug fix or feature
|
||||||
|
2. 📖 **Read Architecture:** `/docs/architecture.md` (understand module structure)
|
||||||
|
3. 🏗️ **Understand CQRS:** `/docs/QUICK_REFERENCE.md` (command/query handlers)
|
||||||
|
4. 🧪 **Write Tests:** Follow patterns in existing `.spec.ts` files
|
||||||
|
5. 📝 **Update Docs:** When adding features, update relevant docs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Welcome to GoodGo! Happy coding! 🚀**
|
||||||
@@ -65,6 +65,17 @@ describe('PropertySearchServer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('search_properties', () => {
|
describe('search_properties', () => {
|
||||||
|
it('always filters by uppercase ACTIVE status matching Prisma enum', async () => {
|
||||||
|
client._search.mockResolvedValue(makeHits([]));
|
||||||
|
const handler = getToolHandler(server, 'search_properties');
|
||||||
|
|
||||||
|
await handler({ query: '*', page: 1, perPage: 20 });
|
||||||
|
|
||||||
|
const filterBy = client._search.mock.calls[0][0].filter_by as string;
|
||||||
|
expect(filterBy).toContain('status:=ACTIVE');
|
||||||
|
expect(filterBy).not.toContain('status:=active');
|
||||||
|
});
|
||||||
|
|
||||||
it('searches with basic query', async () => {
|
it('searches with basic query', async () => {
|
||||||
const handler = getToolHandler(server, 'search_properties');
|
const handler = getToolHandler(server, 'search_properties');
|
||||||
const result = await handler({ query: 'căn hộ Quận 7', page: 1, perPage: 20 });
|
const result = await handler({ query: 'căn hộ Quận 7', page: 1, perPage: 20 });
|
||||||
@@ -101,7 +112,7 @@ describe('PropertySearchServer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const filterBy = client._search.mock.calls[0][0].filter_by as string;
|
const filterBy = client._search.mock.calls[0][0].filter_by as string;
|
||||||
expect(filterBy).toContain('status:=active');
|
expect(filterBy).toContain('status:=ACTIVE');
|
||||||
expect(filterBy).toContain('propertyType:=apartment');
|
expect(filterBy).toContain('propertyType:=apartment');
|
||||||
expect(filterBy).toContain('transactionType:=sale');
|
expect(filterBy).toContain('transactionType:=sale');
|
||||||
expect(filterBy).toContain('priceVND:>=1000000000');
|
expect(filterBy).toContain('priceVND:>=1000000000');
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function createPropertySearchServer(deps: PropertySearchDeps): McpServer
|
|||||||
'Search property listings using natural language queries with filters.',
|
'Search property listings using natural language queries with filters.',
|
||||||
SearchPropertiesSchema,
|
SearchPropertiesSchema,
|
||||||
async (params: z.infer<z.ZodObject<typeof SearchPropertiesSchema>>) => {
|
async (params: z.infer<z.ZodObject<typeof SearchPropertiesSchema>>) => {
|
||||||
const filters: string[] = ['status:=active'];
|
const filters: string[] = ['status:=ACTIVE'];
|
||||||
|
|
||||||
if (params.propertyType) filters.push(`propertyType:=${params.propertyType}`);
|
if (params.propertyType) filters.push(`propertyType:=${params.propertyType}`);
|
||||||
if (params.transactionType) filters.push(`transactionType:=${params.transactionType}`);
|
if (params.transactionType) filters.push(`transactionType:=${params.transactionType}`);
|
||||||
|
|||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -168,6 +168,9 @@ importers:
|
|||||||
class-validator:
|
class-validator:
|
||||||
specifier: ^0.15.1
|
specifier: ^0.15.1
|
||||||
version: 0.15.1
|
version: 0.15.1
|
||||||
|
cockatiel:
|
||||||
|
specifier: ^3.2.1
|
||||||
|
version: 3.2.1
|
||||||
cookie-parser:
|
cookie-parser:
|
||||||
specifier: ^1.4.7
|
specifier: ^1.4.7
|
||||||
version: 1.4.7
|
version: 1.4.7
|
||||||
@@ -4043,6 +4046,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
cockatiel@3.2.1:
|
||||||
|
resolution: {integrity: sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
@@ -11440,6 +11447,8 @@ snapshots:
|
|||||||
|
|
||||||
cluster-key-slot@1.1.2: {}
|
cluster-key-slot@1.1.2: {}
|
||||||
|
|
||||||
|
cockatiel@3.2.1: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "UsageRecord_subscriptionId_metric_periodStart_periodEnd_key" ON "UsageRecord"("subscriptionId", "metric", "periodStart", "periodEnd");
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
-- Add ROOM_RENTAL, CONDOTEL, and SERVICED_APARTMENT to the PropertyType enum.
|
||||||
|
-- These new values support phòng trọ (room rentals), condotels, and serviced apartment listings.
|
||||||
|
|
||||||
|
ALTER TYPE "PropertyType" ADD VALUE 'ROOM_RENTAL';
|
||||||
|
ALTER TYPE "PropertyType" ADD VALUE 'CONDOTEL';
|
||||||
|
ALTER TYPE "PropertyType" ADD VALUE 'SERVICED_APARTMENT';
|
||||||
@@ -265,6 +265,9 @@ enum PropertyType {
|
|||||||
LAND
|
LAND
|
||||||
OFFICE
|
OFFICE
|
||||||
SHOPHOUSE
|
SHOPHOUSE
|
||||||
|
ROOM_RENTAL
|
||||||
|
CONDOTEL
|
||||||
|
SERVICED_APARTMENT
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TransactionType {
|
enum TransactionType {
|
||||||
@@ -757,6 +760,7 @@ model UsageRecord {
|
|||||||
periodStart DateTime
|
periodStart DateTime
|
||||||
periodEnd DateTime
|
periodEnd DateTime
|
||||||
|
|
||||||
|
@@unique([subscriptionId, metric, periodStart, periodEnd])
|
||||||
@@index([subscriptionId, metric])
|
@@index([subscriptionId, metric])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1064,10 +1068,10 @@ model IndustrialPark {
|
|||||||
remainingAreaHa Float
|
remainingAreaHa Float
|
||||||
tenantCount Int @default(0)
|
tenantCount Int @default(0)
|
||||||
establishedYear Int?
|
establishedYear Int?
|
||||||
landRentUsdM2Year Float?
|
landRentUsdM2Year Decimal? @db.Decimal(18, 4)
|
||||||
rbfRentUsdM2Month Float?
|
rbfRentUsdM2Month Decimal? @db.Decimal(18, 4)
|
||||||
rbwRentUsdM2Month Float?
|
rbwRentUsdM2Month Decimal? @db.Decimal(18, 4)
|
||||||
managementFeeUsd Float?
|
managementFeeUsd Decimal? @db.Decimal(18, 4)
|
||||||
infrastructure Json? // { electricity, water, wastewater, telecom, roads, fire }
|
infrastructure Json? // { electricity, water, wastewater, telecom, roads, fire }
|
||||||
connectivity Json? // { nearestPort, airport, highway, railway, seaport }
|
connectivity Json? // { nearestPort, airport, highway, railway, seaport }
|
||||||
incentives Json? // { taxHoliday, importDuty, landRentReduction, specialZone }
|
incentives Json? // { taxHoliday, importDuty, landRentReduction, specialZone }
|
||||||
@@ -1121,10 +1125,10 @@ model IndustrialListing {
|
|||||||
hasMezzanine Boolean @default(false)
|
hasMezzanine Boolean @default(false)
|
||||||
hasOfficeArea Boolean @default(false)
|
hasOfficeArea Boolean @default(false)
|
||||||
officeAreaM2 Float?
|
officeAreaM2 Float?
|
||||||
priceUsdM2 Float?
|
priceUsdM2 Decimal? @db.Decimal(18, 4)
|
||||||
pricingUnit String? // "usd/m2/month", "usd/m2/year"
|
pricingUnit String? // "usd/m2/month", "usd/m2/year"
|
||||||
totalLeasePrice Float?
|
totalLeasePrice Decimal? @db.Decimal(18, 4)
|
||||||
managementFee Float?
|
managementFee Decimal? @db.Decimal(18, 4)
|
||||||
depositMonths Int?
|
depositMonths Int?
|
||||||
minLeaseYears Int?
|
minLeaseYears Int?
|
||||||
maxLeaseYears Int?
|
maxLeaseYears Int?
|
||||||
|
|||||||
215
prisma/scripts/seed-plans.ts
Normal file
215
prisma/scripts/seed-plans.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* seed-plans.ts
|
||||||
|
*
|
||||||
|
* Seeds all 4 subscription plan tiers with Vietnamese market pricing.
|
||||||
|
* Called from prisma/seed.ts Phase 1 before any other data is inserted.
|
||||||
|
* Idempotent — uses upsert on the unique `tier` field.
|
||||||
|
*
|
||||||
|
* Pricing (monthly / yearly):
|
||||||
|
* FREE — 0 / 0 VND
|
||||||
|
* AGENT_PRO — 499,000 / 4,990,000 VND (~$20/month, market-competitive)
|
||||||
|
* INVESTOR — 999,000 / 9,990,000 VND
|
||||||
|
* ENTERPRISE — 4,990,000 / 49,900,000 VND
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import { PrismaClient, PlanTier } from '@prisma/client';
|
||||||
|
import pg from 'pg';
|
||||||
|
|
||||||
|
const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
const PLANS: Array<{
|
||||||
|
id: string;
|
||||||
|
tier: PlanTier;
|
||||||
|
name: string;
|
||||||
|
priceMonthlyVND: bigint;
|
||||||
|
priceYearlyVND: bigint;
|
||||||
|
maxListings: number | null;
|
||||||
|
maxSavedSearches: number | null;
|
||||||
|
maxAnalyticsQueries: number | null;
|
||||||
|
maxReports: number | null;
|
||||||
|
maxMediaUploads: number | null;
|
||||||
|
featuredListingsQuota: number | null;
|
||||||
|
features: Record<string, unknown>;
|
||||||
|
isActive: boolean;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
id: 'plan-free',
|
||||||
|
tier: PlanTier.FREE,
|
||||||
|
name: 'Miễn phí',
|
||||||
|
priceMonthlyVND: 0n,
|
||||||
|
priceYearlyVND: 0n,
|
||||||
|
maxListings: 3,
|
||||||
|
maxSavedSearches: 5,
|
||||||
|
maxAnalyticsQueries: 0,
|
||||||
|
maxReports: 0,
|
||||||
|
maxMediaUploads: 15, // 3 listings × 5 photos
|
||||||
|
featuredListingsQuota: 0,
|
||||||
|
features: {
|
||||||
|
basicSearch: true,
|
||||||
|
listingPost: true,
|
||||||
|
maxPhotos: 5,
|
||||||
|
analytics: false,
|
||||||
|
prioritySupport: false,
|
||||||
|
aiValuation: false,
|
||||||
|
featuredListing: false,
|
||||||
|
leadManagement: false,
|
||||||
|
agentProfile: false,
|
||||||
|
marketReports: false,
|
||||||
|
priceAlerts: false,
|
||||||
|
portfolioTracking: false,
|
||||||
|
apiAccess: false,
|
||||||
|
whiteLabel: false,
|
||||||
|
dedicatedSupport: false,
|
||||||
|
},
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'plan-agent-pro',
|
||||||
|
tier: PlanTier.AGENT_PRO,
|
||||||
|
name: 'Agent Pro',
|
||||||
|
priceMonthlyVND: 499_000n,
|
||||||
|
priceYearlyVND: 4_990_000n,
|
||||||
|
maxListings: 50,
|
||||||
|
maxSavedSearches: 30,
|
||||||
|
maxAnalyticsQueries: 500,
|
||||||
|
maxReports: 20,
|
||||||
|
maxMediaUploads: 1_500, // 50 listings × 30 photos
|
||||||
|
featuredListingsQuota: 5,
|
||||||
|
features: {
|
||||||
|
basicSearch: true,
|
||||||
|
listingPost: true,
|
||||||
|
maxPhotos: 30,
|
||||||
|
analytics: true,
|
||||||
|
prioritySupport: true,
|
||||||
|
aiValuation: true,
|
||||||
|
featuredListing: true,
|
||||||
|
leadManagement: true,
|
||||||
|
agentProfile: true,
|
||||||
|
marketReports: false,
|
||||||
|
priceAlerts: false,
|
||||||
|
portfolioTracking: false,
|
||||||
|
apiAccess: false,
|
||||||
|
whiteLabel: false,
|
||||||
|
dedicatedSupport: false,
|
||||||
|
},
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'plan-investor',
|
||||||
|
tier: PlanTier.INVESTOR,
|
||||||
|
name: 'Investor',
|
||||||
|
priceMonthlyVND: 999_000n,
|
||||||
|
priceYearlyVND: 9_990_000n,
|
||||||
|
maxListings: 20,
|
||||||
|
maxSavedSearches: 100,
|
||||||
|
maxAnalyticsQueries: 2_000,
|
||||||
|
maxReports: 100,
|
||||||
|
maxMediaUploads: 300, // 20 listings × 15 photos
|
||||||
|
featuredListingsQuota: 0,
|
||||||
|
features: {
|
||||||
|
basicSearch: true,
|
||||||
|
listingPost: true,
|
||||||
|
maxPhotos: 15,
|
||||||
|
analytics: true,
|
||||||
|
prioritySupport: true,
|
||||||
|
aiValuation: true,
|
||||||
|
featuredListing: false,
|
||||||
|
leadManagement: false,
|
||||||
|
agentProfile: false,
|
||||||
|
marketReports: true,
|
||||||
|
priceAlerts: true,
|
||||||
|
portfolioTracking: true,
|
||||||
|
apiAccess: false,
|
||||||
|
whiteLabel: false,
|
||||||
|
dedicatedSupport: false,
|
||||||
|
},
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'plan-enterprise',
|
||||||
|
tier: PlanTier.ENTERPRISE,
|
||||||
|
name: 'Enterprise',
|
||||||
|
priceMonthlyVND: 4_990_000n,
|
||||||
|
priceYearlyVND: 49_900_000n,
|
||||||
|
maxListings: null, // unlimited
|
||||||
|
maxSavedSearches: null, // unlimited
|
||||||
|
maxAnalyticsQueries: null, // unlimited
|
||||||
|
maxReports: null, // unlimited
|
||||||
|
maxMediaUploads: null, // unlimited
|
||||||
|
featuredListingsQuota: null, // unlimited
|
||||||
|
features: {
|
||||||
|
basicSearch: true,
|
||||||
|
listingPost: true,
|
||||||
|
maxPhotos: 100,
|
||||||
|
analytics: true,
|
||||||
|
prioritySupport: true,
|
||||||
|
aiValuation: true,
|
||||||
|
featuredListing: true,
|
||||||
|
leadManagement: true,
|
||||||
|
agentProfile: true,
|
||||||
|
marketReports: true,
|
||||||
|
priceAlerts: true,
|
||||||
|
portfolioTracking: true,
|
||||||
|
apiAccess: true,
|
||||||
|
whiteLabel: true,
|
||||||
|
dedicatedSupport: true,
|
||||||
|
},
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function seedPlans(): Promise<void> {
|
||||||
|
console.log('💳 Seeding subscription plans...');
|
||||||
|
|
||||||
|
for (const plan of PLANS) {
|
||||||
|
await prisma.plan.upsert({
|
||||||
|
where: { tier: plan.tier },
|
||||||
|
update: {
|
||||||
|
name: plan.name,
|
||||||
|
priceMonthlyVND: plan.priceMonthlyVND,
|
||||||
|
priceYearlyVND: plan.priceYearlyVND,
|
||||||
|
maxListings: plan.maxListings,
|
||||||
|
maxSavedSearches: plan.maxSavedSearches,
|
||||||
|
maxAnalyticsQueries: plan.maxAnalyticsQueries,
|
||||||
|
maxReports: plan.maxReports,
|
||||||
|
maxMediaUploads: plan.maxMediaUploads,
|
||||||
|
featuredListingsQuota: plan.featuredListingsQuota,
|
||||||
|
features: plan.features,
|
||||||
|
isActive: plan.isActive,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: plan.id,
|
||||||
|
tier: plan.tier,
|
||||||
|
name: plan.name,
|
||||||
|
priceMonthlyVND: plan.priceMonthlyVND,
|
||||||
|
priceYearlyVND: plan.priceYearlyVND,
|
||||||
|
maxListings: plan.maxListings,
|
||||||
|
maxSavedSearches: plan.maxSavedSearches,
|
||||||
|
maxAnalyticsQueries: plan.maxAnalyticsQueries,
|
||||||
|
maxReports: plan.maxReports,
|
||||||
|
maxMediaUploads: plan.maxMediaUploads,
|
||||||
|
featuredListingsQuota: plan.featuredListingsQuota,
|
||||||
|
features: plan.features,
|
||||||
|
isActive: plan.isActive,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ✓ ${PLANS.length} plans seeded (FREE, AGENT_PRO, INVESTOR, ENTERPRISE)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow running standalone: `npx ts-node prisma/scripts/seed-plans.ts`
|
||||||
|
if (require.main === module) {
|
||||||
|
seedPlans()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user