Compare commits

..

7 Commits

Author SHA1 Message Date
Ho Ngoc Hai
8706fff92f feat(auth): prevent soft-deleted users from authenticating (GOO-15)
Some checks failed
CI / AI Services (Python) — Smoke (push) Failing after 26s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 2m37s
Deploy / Build Web Image (push) Failing after 1m9s
Deploy / Build AI Services Image (push) Failing after 37s
E2E Tests / Playwright E2E (push) Failing after 56s
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 11m58s
Deploy / Build API Image (push) Failing after 12m43s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 9s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m27s
Security Scanning / Trivy Scan — Web Image (push) Failing after 43s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 30s
Security Scanning / Trivy Filesystem Scan (push) Failing after 32s
Security Scanning / Security Gate (push) Failing after 1s
CI / E2E Tests (push) Has been cancelled
Deploy / Deploy to Staging (push) Has been cancelled
Deploy / Smoke Test Staging (push) Has been cancelled
Deploy / Rollback Staging (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled
Deploy / Smoke Test Production (push) Has been cancelled
Deploy / Rollback Production (push) Has been cancelled
- Add deletedAt field to UserProps interface and UserEntity
- Map raw.deletedAt in PrismaUserRepository.toDomain()
- Check deletedAt !== null in LocalStrategy.validate() → 401 Tài khoản đã bị xóa
- Update existing LocalStrategy tests with deletedAt: null on valid mocks
- Add test: soft-deleted user login → 401

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-22 23:40:43 +07:00
Ho Ngoc Hai
23af73496d fix(projects): replace \$queryRawUnsafe with Prisma.sql tagged templates in search
- Replace both \$queryRawUnsafe calls in search() with \$queryRaw + Prisma.sql/Prisma.join
- Remove no-op .replace() regex on positional parameters (A-23)
- Change import type { Prisma } to import { Prisma } so Prisma.sql/Prisma.join
  are available as runtime values

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-22 23:39:29 +07:00
Ho Ngoc Hai
7e2ccdfb7c feat(web): add mobile swipe gestures to image gallery
Install react-swipeable and wire useSwipeable onto the main image
container — left-swipe advances to next image, right-swipe goes back.
Gestures only activate when there are multiple images; desktop button
navigation is fully preserved.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-22 23:31:31 +07:00
Ho Ngoc Hai
e798468e4c docs(GOO-33): comprehensive documentation sprint
Create/update all Sprint 6 documentation:
- CHANGELOG.md: document GOO-33 and recent audit findings
- CONTRIBUTING.md: add branching, PR, commit conventions
- docs/ci-cd.md: GitHub Actions pipeline documentation
- docs/onboarding.md: developer setup & onboarding guide
- docs/mcp-servers.md: MCP servers API documentation
- docs/PROJECT_TRACKER.md: mark GOO-33 as in_progress
- docs/QA_TRACKER.md: test status and verification plans

Curate audit reports (reduce ~103 → 12 canonical files):
- Keep canonical audit reports with descriptive index
- Archive obsolete/duplicate audit exploration files

Acceptance Criteria:
- [x] QA_TRACKER.md exists with current test status
- [x] CHANGELOG.md updated to today
- [x] PROJECT_TRACKER.md reflects current sprint status
- [x] CI/CD pipeline documented
- [x] CONTRIBUTING.md has branching, PR, commit conventions
- [x] docs/audits/ reduced to canonical reports

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-22 23:29:20 +07:00
Ho Ngoc Hai
c478abae38 feat(listings): add ROOM_RENTAL, CONDOTEL, SERVICED_APARTMENT property types (GOO-20)
- Add ROOM_RENTAL, CONDOTEL, SERVICED_APARTMENT to PropertyType enum in schema.prisma
- Create migration 20260422010000_add_room_rental_property_types with ALTER TYPE ADD VALUE
- Add DEFAULT_RANGES in PrismaPriceValidator: ROOM_RENTAL 1M-10M VND/month, CONDOTEL 20M-300M, SERVICED_APARTMENT 20M-250M VND/m²
- Add i18n translations: vi "Phòng trọ / Condotel / Căn hộ dịch vụ", en "Room Rental / Condotel / Serviced Apartment"
- Typesense indexes propertyType as a generic string facet — no schema change needed

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-22 23:26:01 +07:00
Ho Ngoc Hai
ee6d6d4c17 fix(subscriptions): atomic UsageRecord metering to prevent quota bypass
- Add @@unique([subscriptionId, metric, periodStart, periodEnd]) constraint
  to UsageRecord model with corresponding migration
- Replace racy findFirst+update/create pattern with Prisma upsert using
  INSERT ON CONFLICT DO UPDATE SET count = count + delta
- Fix CheckQuotaHandler to use period-scoped findUnique instead of
  unscoped findFirst, preventing stale cross-period reads
- Update tests to reflect atomic upsert pattern

Closes GOO-4

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-22 23:22:59 +07:00
Ho Ngoc Hai
65bd641e1f feat(auth): rate-limit POST /auth/exchange-token
Add @Throttle and @EndpointRateLimit decorators to the exchangeToken
endpoint matching other auth endpoints (20/hour per throttler, 5/60s
per IP via EndpointRateLimitGuard). Also adds 429 Swagger response and
integration tests for the happy path and invalid-token 401 case.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-22 23:21:23 +07:00
45 changed files with 2945 additions and 234 deletions

View File

@@ -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=

View File

@@ -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ị

View File

@@ -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``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****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! 🚀**

View File

@@ -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);
});
});
}); });

View File

@@ -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: [],

View File

@@ -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 });
});
}); });

View File

@@ -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',
}); });

View File

@@ -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,

View File

@@ -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;
}
} }

View File

@@ -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');

View File

@@ -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,

View File

@@ -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 (1M10M 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 */

View File

@@ -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();
});
}); });

View File

@@ -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();
});
}); });

View File

@@ -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,

View File

@@ -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;
} }

View File

@@ -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,
}; };

View File

@@ -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`
SELECT p.*, ST_Y(p.location::geometry) as lat, ST_X(p.location::geometry) as lng,
COUNT(pr.id)::int as "propertyCount" COUNT(pr.id)::int as "propertyCount"
FROM "ProjectDevelopment" p FROM "ProjectDevelopment" p
LEFT JOIN "Property" pr ON pr."projectDevelopmentId" = p.id LEFT JOIN "Property" pr ON pr."projectDevelopmentId" = p.id
WHERE ${where.replace(/\b(\$\d+)/g, (_, m) => m)} WHERE ${where}
GROUP BY p.id GROUP BY p.id
ORDER BY p."createdAt" DESC ORDER BY p."createdAt" DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex}`, LIMIT ${limit} OFFSET ${offset}
...values, limit, offset, `,
); );
return { return {

View File

@@ -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);

View File

@@ -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,

View File

@@ -40,25 +40,20 @@ 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, subscriptionId: subscription.id,
metric: command.metric, metric: command.metric,
periodStart: subscription.currentPeriodStart, periodStart: subscription.currentPeriodStart,
periodEnd: subscription.currentPeriodEnd, periodEnd: subscription.currentPeriodEnd,
}, },
}); },
update: {
let usageRecord; count: { increment: command.count },
if (existing) { },
usageRecord = await this.prisma.usageRecord.update({ create: {
where: { id: existing.id },
data: { count: existing.count + command.count },
});
} else {
usageRecord = await this.prisma.usageRecord.create({
data: {
subscriptionId: subscription.id, subscriptionId: subscription.id,
metric: command.metric, metric: command.metric,
count: command.count, count: command.count,
@@ -66,7 +61,6 @@ export class MeterUsageHandler implements ICommandHandler<MeterUsageCommand> {
periodEnd: subscription.currentPeriodEnd, 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(

View File

@@ -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,

View File

@@ -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' },
]; ];

View File

@@ -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>

View File

@@ -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>

View File

@@ -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' },
]; ];

View File

@@ -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' },

View File

@@ -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) => {

View File

@@ -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"

View 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;

View File

@@ -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",

View File

@@ -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",

View File

@@ -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
View 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)

View 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
View 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()``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
View 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
View 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! 🚀**

View File

@@ -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');

View File

@@ -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
View File

@@ -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

View File

@@ -0,0 +1,2 @@
-- CreateIndex
CREATE UNIQUE INDEX "UsageRecord_subscriptionId_metric_periodStart_periodEnd_key" ON "UsageRecord"("subscriptionId", "metric", "periodStart", "periodEnd");

View File

@@ -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';

View File

@@ -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?

View 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();
});
}