feat: comprehensive seed, Lucide icons, grouped dashboard nav, API fixes
- Rewrite prisma/seed.ts to populate all 27 models with realistic Vietnamese real estate data (8 users with login, 10 properties, 10 listings, orders, payments, reviews, notifications, etc.) - Replace all emoji icons with Lucide React SVG icons across frontend for consistent rendering, sizing, and accessibility - Redesign dashboard nav: grouped sidebar with section headers, primary/secondary split on desktop, icon-only secondary items - Replace language switcher flag emoji with Globe icon - Replace SVG theme toggle with Lucide Moon/Sun icons - Fix API startup: graceful fallback for Sentry profiling, Google OAuth, and Zalo OAuth when credentials are not configured - Relax rate limiting in development mode (10k req/min) - Fix listings API to include media[] array in search response - Add optional chaining for property.media across frontend components - Update OAuth strategy tests to match graceful fallback behavior Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,415 +1,192 @@
|
||||
# Quick Reference: Pricing/Subscription/Payment System
|
||||
# GoodGo Platform - Authentication Quick Reference
|
||||
|
||||
## Files at a Glance
|
||||
## 🔑 Key Points at a Glance
|
||||
|
||||
### 🎨 Frontend
|
||||
|
||||
| File | Purpose | Status |
|
||||
|------|---------|--------|
|
||||
| `apps/web/app/[locale]/(public)/pricing/page.tsx` | Main pricing page | ✅ Complete |
|
||||
| `apps/web/lib/subscription-api.ts` | Subscription API client | ✅ Complete |
|
||||
| `apps/web/lib/payment-api.ts` | Payment API client | ✅ Complete |
|
||||
| `apps/web/lib/hooks/use-subscription.ts` | Subscription hooks | ✅ Complete |
|
||||
| `apps/web/lib/hooks/use-payments.ts` | Payment hooks | ✅ Complete |
|
||||
| `apps/web/app/.../dashboard/payments/page.tsx` | Payment history | ✅ Complete |
|
||||
|
||||
### 🔧 Backend
|
||||
|
||||
| Directory | Purpose | Status |
|
||||
|-----------|---------|--------|
|
||||
| `apps/api/src/modules/subscriptions/` | Subscription CQRS module | ✅ Complete |
|
||||
| `apps/api/src/modules/payments/` | Payment CQRS module | ✅ Complete |
|
||||
| `apps/api/src/modules/payments/infrastructure/services/` | Payment gateways (VNPay, MoMo, ZaloPay) | ✅ Complete |
|
||||
|
||||
### 📦 Database
|
||||
|
||||
| Model | Fields | Relationships |
|
||||
|-------|--------|---|
|
||||
| `Plan` | id, tier (unique), name, prices, features, isActive | 1→M Subscription |
|
||||
| `Subscription` | id, userId (unique), planId, status, periods, cancelledAt | M←1 Plan, 1←1 User |
|
||||
| `Payment` | id, userId, provider, type, amountVND, status, providerTxId, idempotencyKey | M←1 User |
|
||||
| `UsageRecord` | id, subscriptionId, metric, count, periods | M←1 Subscription |
|
||||
|
||||
---
|
||||
|
||||
## Key API Endpoints
|
||||
|
||||
### Plans (Public)
|
||||
### Password Hashing
|
||||
```
|
||||
GET /subscriptions/plans
|
||||
GET /subscriptions/plans/:tier
|
||||
Algorithm: bcrypt
|
||||
Salt Rounds: 12 (env: BCRYPT_ROUNDS)
|
||||
Min Length: 8 characters
|
||||
Example: bcrypt.hash('password', 12)
|
||||
```
|
||||
|
||||
### Subscriptions (Auth Required)
|
||||
### Phone Numbers (Vietnamese)
|
||||
```
|
||||
POST /subscriptions # Create new
|
||||
PUT /subscriptions/upgrade # Upgrade
|
||||
DELETE /subscriptions # Cancel
|
||||
GET /subscriptions/quota/:metric # Check quota
|
||||
POST /subscriptions/usage # Record usage
|
||||
GET /subscriptions/billing # View history
|
||||
Valid Formats: 0900000001, 84900000001, +84900000001
|
||||
Normalized: +84900000001
|
||||
Regex: /^(?:\+84|84|0)(3[2-9]|5[2689]|7[06-9]|8[1-9]|9[0-9])\d{7}$/
|
||||
File: apps/api/src/modules/shared/utils/vietnam-phone.validator.ts
|
||||
```
|
||||
|
||||
### Payments (Auth + Webhook)
|
||||
### Email
|
||||
```
|
||||
POST /payments # Create payment → returns paymentUrl
|
||||
POST /payments/callback/:provider # Webhook from gateway
|
||||
GET /payments/:id # Check status
|
||||
GET /payments # List transactions
|
||||
POST /payments/:id/refund # Refund (admin)
|
||||
Regex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
Normalization: lowercase + trim
|
||||
Storage: admin@goodgo.vn
|
||||
```
|
||||
|
||||
### PII Encryption
|
||||
```
|
||||
Algorithm: AES-256-GCM
|
||||
Key: 32 bytes (64 hex chars)
|
||||
Encrypted: email, phone, kycData
|
||||
Searchable: email → emailHash (HMAC-SHA256)
|
||||
phone → phoneHash (HMAC-SHA256)
|
||||
Env Var: FIELD_ENCRYPTION_KEY
|
||||
```
|
||||
|
||||
### User Login
|
||||
```
|
||||
Username: phone (normalized)
|
||||
Password: plain text
|
||||
Lookup: by phoneHash (unique index)
|
||||
Required: isActive = true, passwordHash ≠ null
|
||||
Response: tokens (or MFA challenge)
|
||||
```
|
||||
|
||||
### User Roles
|
||||
```
|
||||
BUYER - Search, inquire, offer (default)
|
||||
SELLER - Create listings
|
||||
AGENT - Professional agent
|
||||
ADMIN - Full access
|
||||
```
|
||||
|
||||
### MFA
|
||||
```
|
||||
TOTP: otplib (RFC 6238)
|
||||
Period: 30 seconds
|
||||
Digits: 6
|
||||
Backup Codes: 10 × 8 chars (A-Z no OI, 2-9 no 01)
|
||||
Hashing: HMAC-SHA256 (not bcrypt)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Type Definitions
|
||||
## 📋 Creating a Login-Capable Admin User
|
||||
|
||||
### Frontend Types
|
||||
### 5-Step Process
|
||||
|
||||
**1. Normalize phone**
|
||||
```typescript
|
||||
// From subscription-api.ts
|
||||
interface PlanDto {
|
||||
id: string;
|
||||
tier: string; // FREE, AGENT_PRO, INVESTOR, ENTERPRISE
|
||||
name: string;
|
||||
priceMonthlyVND: string; // In VND
|
||||
priceYearlyVND: string; // In VND
|
||||
maxListings: number;
|
||||
maxSavedSearches: number;
|
||||
features: Record<string, boolean | number | string>;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface CreateSubscriptionResult {
|
||||
subscriptionId: string;
|
||||
planTier: string;
|
||||
status: string; // ACTIVE, PAST_DUE, CANCELLED, EXPIRED
|
||||
currentPeriodStart: string; // ISO datetime
|
||||
currentPeriodEnd: string; // ISO datetime
|
||||
}
|
||||
|
||||
// From payment-api.ts
|
||||
interface CreatePaymentPayload {
|
||||
provider: 'VNPAY' | 'MOMO' | 'ZALOPAY' | 'BANK_TRANSFER';
|
||||
type: 'SUBSCRIPTION' | 'LISTING_FEE' | 'DEPOSIT' | 'FEATURED_LISTING';
|
||||
amountVND: number; // 1 to 100,000,000,000
|
||||
description: string;
|
||||
returnUrl: string; // Redirect after payment
|
||||
idempotencyKey?: string; // Prevent duplicates
|
||||
transactionId?: string; // External transaction ID
|
||||
}
|
||||
|
||||
interface CreatePaymentResult {
|
||||
paymentId: string;
|
||||
paymentUrl: string; // Redirect user here
|
||||
providerTxId: string;
|
||||
}
|
||||
|
||||
interface PaymentStatusDto {
|
||||
id: string;
|
||||
provider: string;
|
||||
type: string;
|
||||
amountVND: string;
|
||||
status: string; // PENDING, PROCESSING, COMPLETED, FAILED, REFUNDED
|
||||
providerTxId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
phone = '0900000001' → '+84900000001'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How to Use in Frontend
|
||||
|
||||
### Get Plans
|
||||
**2. Derive HMAC key**
|
||||
```typescript
|
||||
import { usePlans } from '@/lib/hooks/use-subscription';
|
||||
|
||||
export function MyComponent() {
|
||||
const { data: plans, isLoading } = usePlans();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isLoading ? 'Loading...' : plans?.map(plan => <div>{plan.name}</div>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
hmacKey = crypto.hkdfSync('sha256', Buffer.from(encryptionKey, 'hex'),
|
||||
Buffer.alloc(0), Buffer.from('goodgo-field-hash', 'utf8'), 32)
|
||||
```
|
||||
|
||||
### Create Payment
|
||||
**3. Compute hashes**
|
||||
```typescript
|
||||
import { paymentApi } from '@/lib/payment-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
phoneHash = crypto.createHmac('sha256', hmacKey).update('+84900000001').digest('hex')
|
||||
emailHash = crypto.createHmac('sha256', hmacKey).update('admin@goodgo.vn').digest('hex')
|
||||
```
|
||||
|
||||
const createPaymentMutation = useMutation({
|
||||
mutationFn: (payload) => paymentApi.createPayment(payload),
|
||||
**4. Hash password**
|
||||
```typescript
|
||||
passwordHash = await bcrypt.hash('AdminPassword123', 12)
|
||||
```
|
||||
|
||||
**5. Create user**
|
||||
```typescript
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: 'admin-seed-001',
|
||||
phone: '+84900000001',
|
||||
phoneHash,
|
||||
email: 'admin@goodgo.vn',
|
||||
emailHash,
|
||||
passwordHash,
|
||||
fullName: 'Admin',
|
||||
role: 'ADMIN',
|
||||
kycStatus: 'VERIFIED',
|
||||
isActive: true,
|
||||
totpEnabled: false,
|
||||
totpBackupCodes: [],
|
||||
},
|
||||
});
|
||||
|
||||
// When user clicks "Pay Now"
|
||||
const handlePayment = async (planTier: string, provider: 'VNPAY' | 'MOMO' | 'ZALOPAY') => {
|
||||
const result = await createPaymentMutation.mutateAsync({
|
||||
provider,
|
||||
type: 'SUBSCRIPTION',
|
||||
amountVND: 499000,
|
||||
description: `Subscription to ${planTier}`,
|
||||
returnUrl: `${window.location.origin}/payment-return`,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
});
|
||||
|
||||
// Redirect to payment gateway
|
||||
window.location = result.paymentUrl;
|
||||
};
|
||||
```
|
||||
|
||||
### Check Payment Status (on return page)
|
||||
```typescript
|
||||
import { paymentApi } from '@/lib/payment-api';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function PaymentReturnPage() {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const paymentId = searchParams.get('paymentId');
|
||||
const [status, setStatus] = useState<string>('loading');
|
||||
|
||||
useEffect(() => {
|
||||
if (!paymentId) return;
|
||||
|
||||
const poll = async () => {
|
||||
const payment = await paymentApi.getPaymentStatus(paymentId);
|
||||
|
||||
if (payment.status === 'COMPLETED') {
|
||||
// Create subscription
|
||||
await subscriptionApi.createSubscription('AGENT_PRO', 'monthly');
|
||||
setStatus('success');
|
||||
// Redirect to dashboard
|
||||
window.location = '/dashboard';
|
||||
} else if (payment.status === 'FAILED') {
|
||||
setStatus('failed');
|
||||
} else {
|
||||
// Poll again in 2 seconds
|
||||
setTimeout(poll, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
}, [paymentId]);
|
||||
|
||||
return <div>{status === 'loading' ? 'Processing payment...' : status}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Create Subscription
|
||||
```typescript
|
||||
import { subscriptionApi } from '@/lib/subscription-api';
|
||||
|
||||
const result = await subscriptionApi.createSubscription('AGENT_PRO', 'monthly');
|
||||
console.log(result);
|
||||
// {
|
||||
// subscriptionId: 'cuid...',
|
||||
// planTier: 'AGENT_PRO',
|
||||
// status: 'ACTIVE',
|
||||
// currentPeriodStart: '2024-04-12T...',
|
||||
// currentPeriodEnd: '2024-05-12T...'
|
||||
// }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How to Use in Backend
|
||||
|
||||
### Create Plan (Admin)
|
||||
```sql
|
||||
INSERT INTO "Plan" (id, tier, name, "priceMonthlyVND", "priceYearlyVND", "maxListings", features, "isActive")
|
||||
VALUES (
|
||||
'cuid123',
|
||||
'AGENT_PRO',
|
||||
'Agent Pro',
|
||||
499000,
|
||||
4990000,
|
||||
50,
|
||||
'{"analytics": true, "aiValuation": true}',
|
||||
true
|
||||
);
|
||||
```
|
||||
|
||||
### Create Payment (via API)
|
||||
```
|
||||
POST /payments
|
||||
Authorization: Bearer <jwt_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"provider": "VNPAY",
|
||||
"type": "SUBSCRIPTION",
|
||||
"amountVND": 499000,
|
||||
"description": "Agent Pro - Monthly",
|
||||
"returnUrl": "https://goodgo.vn/payment-return",
|
||||
"idempotencyKey": "550e8400-e29b-41d4-a716-446655440000"
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"paymentId": "cuid456",
|
||||
"paymentUrl": "https://sandbox.vnpayment.vn/paymentv2/vpcpay.html?...",
|
||||
"providerTxId": "cuid456"
|
||||
}
|
||||
```
|
||||
|
||||
### Handle Payment Callback (Webhook)
|
||||
```
|
||||
POST /payments/callback/vnpay?vnp_TxnRef=cuid456&vnp_ResponseCode=00&vnp_SecureHash=...
|
||||
|
||||
Response:
|
||||
{
|
||||
"orderId": "cuid456",
|
||||
"isSuccess": true,
|
||||
"status": "COMPLETED"
|
||||
}
|
||||
```
|
||||
|
||||
### Create Subscription (via API)
|
||||
```
|
||||
POST /subscriptions
|
||||
Authorization: Bearer <jwt_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"planTier": "AGENT_PRO",
|
||||
"billingCycle": "monthly"
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"subscriptionId": "cuid789",
|
||||
"planTier": "AGENT_PRO",
|
||||
"status": "ACTIVE",
|
||||
"currentPeriodStart": "2024-04-12T...",
|
||||
"currentPeriodEnd": "2024-05-12T..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pricing Structure
|
||||
|
||||
```
|
||||
FREE (0 VND)
|
||||
├── 3 listings
|
||||
├── 5 saved searches
|
||||
└── Basic features
|
||||
|
||||
AGENT_PRO (499,000 VND/month | 4,990,000/year = -17%)
|
||||
├── 50 listings
|
||||
├── 30 saved searches
|
||||
├── Analytics
|
||||
├── AI Valuation
|
||||
├── Priority support
|
||||
└── Lead management
|
||||
|
||||
INVESTOR (999,000 VND/month | 9,990,000/year = -17%)
|
||||
├── 20 listings
|
||||
├── 100 saved searches
|
||||
├── Analytics
|
||||
├── AI Valuation
|
||||
├── Market reports
|
||||
├── Price alerts
|
||||
└── Portfolio tracking
|
||||
|
||||
ENTERPRISE (4,990,000 VND/month | 49,900,000/year = -17%)
|
||||
├── Unlimited listings
|
||||
├── Unlimited searches
|
||||
├── All INVESTOR features
|
||||
├── API access
|
||||
├── White label
|
||||
└── Dedicated support
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
## 🧪 Test Login
|
||||
|
||||
```bash
|
||||
# Backend (.env)
|
||||
VNPAY_TMN_CODE=your_tmn_code
|
||||
VNPAY_HASH_SECRET=your_hash_secret
|
||||
VNPAY_BASE_URL=https://sandbox.vnpayment.vn/paymentv2/vpcpay.html
|
||||
VNPAY_API_URL=https://sandbox.vnpayment.vn/merchant_webapi/api/transaction
|
||||
curl -X POST http://localhost:3000/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"phone": "0900000001",
|
||||
"password": "AdminPassword123"
|
||||
}'
|
||||
```
|
||||
|
||||
MOMO_PARTNER_CODE=your_partner_code
|
||||
MOMO_ACCESS_KEY=your_access_key
|
||||
MOMO_SECRET_KEY=your_secret_key
|
||||
MOMO_ENDPOINT=https://test-payment.momo.vn/v2/gateway/api
|
||||
|
||||
ZALOPAY_APP_ID=your_app_id
|
||||
ZALOPAY_KEY1=your_key1
|
||||
ZALOPAY_KEY2=your_key2
|
||||
ZALOPAY_ENDPOINT=https://sandbox.zalopay.com.vn
|
||||
|
||||
# Frontend (.env.local)
|
||||
NEXT_PUBLIC_APP_URL=https://goodgo.vn
|
||||
**Success Response:**
|
||||
```json
|
||||
{
|
||||
"requiresMfa": false,
|
||||
"tokens": {
|
||||
"accessToken": "eyJ...",
|
||||
"refreshToken": "eyJ...",
|
||||
"expiresIn": 3600
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Credentials
|
||||
## ⚠️ Common Issues
|
||||
|
||||
### VNPay Sandbox
|
||||
```
|
||||
Terminal: 0
|
||||
Account: 0968323286
|
||||
Password: 123456
|
||||
Card: 9704198526191432198
|
||||
OTP: 123456
|
||||
```
|
||||
|
||||
### MoMo Sandbox
|
||||
```
|
||||
Phone: 0987654321
|
||||
Password: 123456
|
||||
OTP: 123456
|
||||
```
|
||||
|
||||
### ZaloPay Sandbox
|
||||
```
|
||||
Phone: 0987654321
|
||||
OTP: 123456
|
||||
```
|
||||
| Issue | Fix |
|
||||
|-------|-----|
|
||||
| User can't login | Check: `passwordHash` ≠ null, `isActive` = true |
|
||||
| "Invalid phone" | Phone must match regex (mobile only) |
|
||||
| Hash mismatch | Verify `FIELD_ENCRYPTION_KEY` is consistent |
|
||||
| MFA issue | Verify `MFA_BACKUP_CODE_SECRET` env var |
|
||||
| PII not encrypted | Verify key is exactly 32 bytes (64 hex chars) |
|
||||
|
||||
---
|
||||
|
||||
## Common Errors
|
||||
## 📁 Key Files
|
||||
|
||||
| Error | Cause | Solution |
|
||||
|-------|-------|----------|
|
||||
| `ConflictException: User already has active subscription` | User trying to create 2nd subscription | Check existing subscription first |
|
||||
| `ValidationException: Số tiền phải lớn hơn 0` | Amount is 0 or negative | Ensure amount > 0 |
|
||||
| `NotFoundException: Plan not found` | Plan tier doesn't exist in DB | Check plan is created and isActive=true |
|
||||
| `Payment gateway failed` | Payment gateway credentials wrong | Verify ENV vars |
|
||||
| `Cannot complete payment in status X` | Payment already completed/failed | Check idempotencyKey |
|
||||
| `Idempotency check failed` | Same idempotencyKey used twice | Generate unique UUID each time |
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `hashed-password.vo.ts` | bcrypt hashing |
|
||||
| `vietnam-phone.validator.ts` | Phone validation |
|
||||
| `field-encryption.ts` | AES-256-GCM encryption |
|
||||
| `local.strategy.ts` | Login endpoint |
|
||||
| `mfa.service.ts` | TOTP / backup codes |
|
||||
| `user.entity.ts` | User domain model |
|
||||
| `prisma-user.repository.ts` | User persistence |
|
||||
| `seed.ts` | Seed script |
|
||||
|
||||
---
|
||||
|
||||
## Debugging Checklist
|
||||
## 🔐 Checklist for Seed User
|
||||
|
||||
- [ ] Check payment provider credentials in .env
|
||||
- [ ] Verify idempotencyKey is unique per request
|
||||
- [ ] Ensure amountVND matches plan price
|
||||
- [ ] Check returnUrl is publicly accessible
|
||||
- [ ] Verify JWT token is valid when calling protected endpoints
|
||||
- [ ] Check payment status with `GET /payments/:id`
|
||||
- [ ] Review payment provider logs/dashboard
|
||||
- [ ] Test with sandbox credentials first
|
||||
- [ ] Verify callback signature matches gateway requirements
|
||||
- [ ] Check subscription was created after successful payment
|
||||
- [ ] Password ≥ 8 chars
|
||||
- [ ] Phone matches regex
|
||||
- [ ] Phone normalized: +84...
|
||||
- [ ] Phone hashed: HMAC-SHA256
|
||||
- [ ] Email lowercased
|
||||
- [ ] Email hashed: HMAC-SHA256
|
||||
- [ ] Password hashed: bcrypt (12 rounds)
|
||||
- [ ] `isActive: true`
|
||||
- [ ] `passwordHash` ≠ null
|
||||
- [ ] `totpEnabled: false`
|
||||
- [ ] `totpBackupCodes: []`
|
||||
|
||||
---
|
||||
|
||||
## Links
|
||||
## 📚 Full Documentation Files
|
||||
|
||||
- Detailed Audit: `PRICING_CHECKOUT_AUDIT.md`
|
||||
- Summary: `PRICING_AUDIT_SUMMARY.md`
|
||||
- Pricing Page: `apps/web/app/[locale]/(public)/pricing/page.tsx`
|
||||
- Subscriptions Module: `apps/api/src/modules/subscriptions/`
|
||||
- Payments Module: `apps/api/src/modules/payments/`
|
||||
- Schema: `prisma/schema.prisma` (lines 451-514)
|
||||
1. **AUTHENTICATION_GUIDE.md** - Complete technical reference
|
||||
2. **AUTH_IMPLEMENTATION_CHECKLIST.md** - Implementation checklist & troubleshooting
|
||||
3. **SEED_GENERATION_SCRIPT.ts** - Ready-to-use seed script
|
||||
4. **QUICK_REFERENCE.md** - This file
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** April 12, 2026
|
||||
**Status:** ✅ Production-Ready
|
||||
|
||||
@@ -55,17 +55,17 @@ import { AppController } from './app.controller';
|
||||
{
|
||||
name: 'default',
|
||||
ttl: 60_000,
|
||||
limit: process.env['NODE_ENV'] === 'test' ? 10_000 : 60,
|
||||
limit: process.env['NODE_ENV'] === 'test' || process.env['NODE_ENV'] === 'development' ? 10_000 : 60,
|
||||
},
|
||||
{
|
||||
name: 'auth',
|
||||
ttl: 60_000,
|
||||
limit: process.env['NODE_ENV'] === 'test' ? 10_000 : 10,
|
||||
limit: process.env['NODE_ENV'] === 'test' || process.env['NODE_ENV'] === 'development' ? 10_000 : 10,
|
||||
},
|
||||
{
|
||||
name: 'payment-callback',
|
||||
ttl: 60_000,
|
||||
limit: process.env['NODE_ENV'] === 'test' ? 10_000 : 20,
|
||||
limit: process.env['NODE_ENV'] === 'test' || process.env['NODE_ENV'] === 'development' ? 10_000 : 20,
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -7,9 +7,14 @@ const isTest = process.env['NODE_ENV'] === 'test';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const integrations: any[] = [];
|
||||
if (!isTest) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { nodeProfilingIntegration } = require('@sentry/profiling-node') as typeof import('@sentry/profiling-node');
|
||||
integrations.push(nodeProfilingIntegration());
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { nodeProfilingIntegration } = require('@sentry/profiling-node') as typeof import('@sentry/profiling-node');
|
||||
integrations.push(nodeProfilingIntegration());
|
||||
} catch {
|
||||
// Native CPU profiler binary not available — skip profiling gracefully.
|
||||
console.warn('[Sentry] Profiling skipped — native module not available');
|
||||
}
|
||||
}
|
||||
|
||||
Sentry.init({
|
||||
|
||||
@@ -45,13 +45,12 @@ describe('GoogleOAuthStrategy', () => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('throws if GOOGLE_CLIENT_ID is missing', () => {
|
||||
it('creates strategy with dummy config when GOOGLE_CLIENT_ID is missing', async () => {
|
||||
vi.stubEnv('GOOGLE_CLIENT_ID', '');
|
||||
// Reset module to pick up new env
|
||||
expect(async () => {
|
||||
const { GoogleOAuthStrategy } = await import('../strategies/google-oauth.strategy');
|
||||
new GoogleOAuthStrategy(mockOAuthService as unknown as OAuthService);
|
||||
}).rejects.toThrow('GOOGLE_CLIENT_ID');
|
||||
const { GoogleOAuthStrategy } = await import('../strategies/google-oauth.strategy');
|
||||
const strategy = new GoogleOAuthStrategy(mockOAuthService as unknown as OAuthService);
|
||||
// Strategy should still be created (graceful fallback with dummy values)
|
||||
expect(strategy).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates strategy with correct config', async () => {
|
||||
|
||||
@@ -30,18 +30,18 @@ describe('ZaloOAuthStrategy', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('throws if ZALO_APP_ID is missing', () => {
|
||||
it('creates strategy with dummy config when ZALO_APP_ID is missing', () => {
|
||||
vi.stubEnv('ZALO_APP_ID', '');
|
||||
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
|
||||
expect(() => new ZaloOAuthStrategy(mockOAuthService as unknown as OAuthService, mockLogger as any))
|
||||
.toThrow('ZALO_APP_ID');
|
||||
const strategy = new ZaloOAuthStrategy(mockOAuthService as unknown as OAuthService, mockLogger as any);
|
||||
expect(strategy).toBeDefined();
|
||||
});
|
||||
|
||||
it('throws if ZALO_APP_SECRET is missing', () => {
|
||||
it('creates strategy with dummy config when ZALO_APP_SECRET is missing', () => {
|
||||
vi.stubEnv('ZALO_APP_SECRET', '');
|
||||
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
|
||||
expect(() => new ZaloOAuthStrategy(mockOAuthService as unknown as OAuthService, mockLogger as any))
|
||||
.toThrow('ZALO_APP_SECRET');
|
||||
const strategy = new ZaloOAuthStrategy(mockOAuthService as unknown as OAuthService, mockLogger as any);
|
||||
expect(strategy).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getAuthorizationUrl', () => {
|
||||
|
||||
@@ -11,7 +11,15 @@ export class GoogleOAuthStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
const callbackURL = process.env['GOOGLE_CALLBACK_URL'] ?? '/auth/google/callback';
|
||||
|
||||
if (!clientID || !clientSecret) {
|
||||
throw new Error('GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET environment variables are required');
|
||||
// Use dummy values so the app can start without Google OAuth configured.
|
||||
// The Google login route will be non-functional until real credentials are set.
|
||||
super({
|
||||
clientID: 'NOT_CONFIGURED',
|
||||
clientSecret: 'NOT_CONFIGURED',
|
||||
callbackURL,
|
||||
scope: ['email', 'profile'],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
super({
|
||||
|
||||
@@ -49,7 +49,11 @@ export class ZaloOAuthStrategy {
|
||||
const appSecret = process.env['ZALO_APP_SECRET'];
|
||||
|
||||
if (!appId || !appSecret) {
|
||||
throw new Error('ZALO_APP_ID and ZALO_APP_SECRET environment variables are required');
|
||||
// Allow app to start without Zalo OAuth configured — routes will be non-functional.
|
||||
this.appId = 'NOT_CONFIGURED';
|
||||
this.appSecret = 'NOT_CONFIGURED';
|
||||
this.callbackUrl = process.env['ZALO_CALLBACK_URL'] ?? '/auth/zalo/callback';
|
||||
return;
|
||||
}
|
||||
|
||||
this.appId = appId;
|
||||
|
||||
@@ -74,6 +74,7 @@ export interface ListingSearchItem {
|
||||
bedrooms: number | null;
|
||||
bathrooms: number | null;
|
||||
thumbnail: string | null;
|
||||
media: ListingMediaData[];
|
||||
};
|
||||
seller: {
|
||||
id: string;
|
||||
@@ -94,5 +95,6 @@ export interface ListingSellerItem {
|
||||
city: string;
|
||||
areaM2: number;
|
||||
thumbnail: string | null;
|
||||
media: ListingMediaData[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ export async function searchListings(
|
||||
include: {
|
||||
property: {
|
||||
include: {
|
||||
media: { orderBy: { order: 'asc' }, take: 1 },
|
||||
media: { orderBy: { order: 'asc' }, take: 5 },
|
||||
},
|
||||
},
|
||||
seller: { select: { id: true, fullName: true } },
|
||||
@@ -135,6 +135,13 @@ export async function searchListings(
|
||||
bedrooms: listing.property.bedrooms,
|
||||
bathrooms: listing.property.bathrooms,
|
||||
thumbnail: listing.property.media[0]?.url ?? null,
|
||||
media: listing.property.media.map((m) => ({
|
||||
id: m.id,
|
||||
url: m.url,
|
||||
type: m.type,
|
||||
order: m.order,
|
||||
caption: m.caption,
|
||||
})),
|
||||
},
|
||||
seller: listing.seller,
|
||||
})),
|
||||
@@ -182,6 +189,13 @@ export async function findBySellerIdQuery(
|
||||
city: listing.property.city,
|
||||
areaM2: listing.property.areaM2,
|
||||
thumbnail: listing.property.media[0]?.url ?? null,
|
||||
media: listing.property.media.map((m) => ({
|
||||
id: m.id,
|
||||
url: m.url,
|
||||
type: m.type,
|
||||
order: m.order,
|
||||
caption: m.caption,
|
||||
})),
|
||||
},
|
||||
})),
|
||||
total,
|
||||
|
||||
@@ -238,9 +238,9 @@ export default function DashboardPage() {
|
||||
className="flex items-center gap-4 rounded-lg border p-3 transition-colors hover:bg-accent"
|
||||
>
|
||||
<div className="relative h-12 w-16 flex-shrink-0 overflow-hidden rounded bg-muted">
|
||||
{listing.property.media.length > 0 ? (
|
||||
{(listing.property.media?.length ?? 0) > 0 ? (
|
||||
<Image
|
||||
src={listing.property.media[0]?.url ?? ''}
|
||||
src={listing.property.media![0]?.url ?? ''}
|
||||
alt={listing.property.title}
|
||||
fill
|
||||
sizes="64px"
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { LogOut, Menu, X } from 'lucide-react';
|
||||
import {
|
||||
BarChart3,
|
||||
Bookmark,
|
||||
Bot,
|
||||
CreditCard,
|
||||
Gem,
|
||||
Home,
|
||||
List,
|
||||
LogOut,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Moon,
|
||||
Plus,
|
||||
Sun,
|
||||
Target,
|
||||
User,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
@@ -11,6 +29,17 @@ import { Link } from '@/i18n/navigation';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
label: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const { user, logout } = useAuthStore();
|
||||
@@ -18,20 +47,61 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
const t = useTranslations();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const navItems = [
|
||||
{ href: '/dashboard' as const, label: t('dashboard.title'), icon: '🏠' },
|
||||
{ href: '/listings' as const, label: t('dashboard.listings'), icon: '📋' },
|
||||
{ href: '/listings/new' as const, label: t('dashboard.createListing'), icon: '➕' },
|
||||
{ href: '/inquiries' as const, label: t('dashboard.inquiries'), icon: '💬' },
|
||||
{ href: '/leads' as const, label: t('dashboard.leads'), icon: '🎯' },
|
||||
{ href: '/analytics' as const, label: t('dashboard.analytics'), icon: '📊' },
|
||||
{ href: '/dashboard/saved-searches' as const, label: t('dashboard.savedSearches'), icon: '🔖' },
|
||||
{ href: '/dashboard/valuation' as const, label: t('dashboard.aiValuation'), icon: '🤖' },
|
||||
{ href: '/dashboard/profile' as const, label: t('dashboard.profile'), icon: '👤' },
|
||||
{ href: '/dashboard/subscription' as const, label: t('dashboard.subscription'), icon: '💎' },
|
||||
{ href: '/dashboard/payments' as const, label: t('dashboard.payments'), icon: '💳' },
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
label: t('dashboard.title'),
|
||||
items: [
|
||||
{ href: '/dashboard', label: t('dashboard.title'), icon: Home },
|
||||
{ href: '/listings', label: t('dashboard.listings'), icon: List },
|
||||
{ href: '/listings/new', label: t('dashboard.createListing'), icon: Plus },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'CRM',
|
||||
items: [
|
||||
{ href: '/inquiries', label: t('dashboard.inquiries'), icon: MessageSquare },
|
||||
{ href: '/leads', label: t('dashboard.leads'), icon: Target },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('dashboard.analytics'),
|
||||
items: [
|
||||
{ href: '/analytics', label: t('dashboard.analytics'), icon: BarChart3 },
|
||||
{ href: '/dashboard/saved-searches', label: t('dashboard.savedSearches'), icon: Bookmark },
|
||||
{ href: '/dashboard/valuation', label: t('dashboard.aiValuation'), icon: Bot },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('dashboard.profile'),
|
||||
items: [
|
||||
{ href: '/dashboard/profile', label: t('dashboard.profile'), icon: User },
|
||||
{ href: '/dashboard/subscription', label: t('dashboard.subscription'), icon: Gem },
|
||||
{ href: '/dashboard/payments', label: t('dashboard.payments'), icon: CreditCard },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Flat list for desktop nav (only primary items shown inline)
|
||||
const primaryNav: NavItem[] = [
|
||||
{ href: '/dashboard', label: t('dashboard.title'), icon: Home },
|
||||
{ href: '/listings', label: t('dashboard.listings'), icon: List },
|
||||
{ href: '/listings/new', label: t('dashboard.createListing'), icon: Plus },
|
||||
{ href: '/inquiries', label: t('dashboard.inquiries'), icon: MessageSquare },
|
||||
{ href: '/leads', label: t('dashboard.leads'), icon: Target },
|
||||
{ href: '/analytics', label: t('dashboard.analytics'), icon: BarChart3 },
|
||||
];
|
||||
|
||||
const secondaryNav: NavItem[] = [
|
||||
{ href: '/dashboard/saved-searches', label: t('dashboard.savedSearches'), icon: Bookmark },
|
||||
{ href: '/dashboard/valuation', label: t('dashboard.aiValuation'), icon: Bot },
|
||||
{ href: '/dashboard/profile', label: t('dashboard.profile'), icon: User },
|
||||
{ href: '/dashboard/subscription', label: t('dashboard.subscription'), icon: Gem },
|
||||
{ href: '/dashboard/payments', label: t('dashboard.payments'), icon: CreditCard },
|
||||
];
|
||||
|
||||
const isActive = (href: string) =>
|
||||
pathname === href || (href !== '/dashboard' && pathname.startsWith(href));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Mobile overlay */}
|
||||
@@ -43,7 +113,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile sidebar */}
|
||||
{/* Mobile sidebar — grouped nav */}
|
||||
<aside
|
||||
role="navigation"
|
||||
aria-label={t('nav.dashboardNav')}
|
||||
@@ -65,22 +135,31 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-1 p-3">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors',
|
||||
pathname === item.href || (item.href !== '/dashboard' && pathname.startsWith(item.href))
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
)}
|
||||
>
|
||||
<span aria-hidden="true">{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
<nav className="flex flex-col gap-4 overflow-y-auto p-3">
|
||||
{navGroups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<p className="mb-1 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground/60">
|
||||
{group.label}
|
||||
</p>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{group.items.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive(item.href)
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -114,33 +193,57 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<Link href="/" className="mr-6 flex items-center space-x-2">
|
||||
<Link href="/" className="mr-4 flex items-center space-x-2">
|
||||
<span className="text-lg font-bold text-primary">{t('common.goodgo')}</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<nav aria-label={t('nav.dashboardNav')} className="hidden items-center space-x-1 md:flex">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-label={item.label}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
pathname === item.href || (item.href !== '/dashboard' && pathname.startsWith(item.href))
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<span className="mr-1.5" aria-hidden="true">{item.icon}</span>
|
||||
<span className="hidden lg:inline">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
{/* Desktop nav — primary items with labels, secondary icon-only */}
|
||||
<nav aria-label={t('nav.dashboardNav')} className="hidden items-center md:flex">
|
||||
<div className="flex items-center">
|
||||
{primaryNav.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-label={item.label}
|
||||
title={item.label}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
isActive(item.href)
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span className="hidden xl:inline">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mx-2 h-5 w-px bg-border" aria-hidden="true" />
|
||||
|
||||
<div className="flex items-center">
|
||||
{secondaryNav.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-label={item.label}
|
||||
title={item.label}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md p-2 transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
isActive(item.href)
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto flex items-center space-x-2">
|
||||
<div className="ml-auto flex items-center space-x-1">
|
||||
{user && (
|
||||
<span className="hidden text-sm text-muted-foreground sm:inline">
|
||||
<span className="hidden text-sm text-muted-foreground lg:inline">
|
||||
{user.fullName}
|
||||
</span>
|
||||
)}
|
||||
@@ -153,17 +256,14 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
className="h-9 w-9 p-0"
|
||||
>
|
||||
{theme === 'light' ? (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
<Moon className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
<Sun className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="hidden md:inline-flex" onClick={() => logout()}>
|
||||
{t('common.logout')}
|
||||
<Button variant="ghost" size="sm" className="hidden gap-1.5 md:inline-flex" onClick={() => logout()}>
|
||||
<LogOut className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="hidden lg:inline">{t('common.logout')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ClipboardList } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import { CreateLeadDialog } from '@/components/leads/create-lead-dialog';
|
||||
import { LeadDetailDialog } from '@/components/leads/lead-detail-dialog';
|
||||
@@ -149,7 +150,7 @@ export default function LeadsPage() {
|
||||
</div>
|
||||
) : !result || result.data.length === 0 ? (
|
||||
<div className="flex min-h-[300px] flex-col items-center justify-center text-muted-foreground">
|
||||
<p className="text-4xl mb-3">📋</p>
|
||||
<ClipboardList className="h-10 w-10 text-muted-foreground mb-3" aria-hidden="true" />
|
||||
<p>Chưa có lead nào</p>
|
||||
<Button variant="outline" size="sm" className="mt-3" onClick={() => setCreateOpen(true)}>
|
||||
Thêm lead đầu tiên
|
||||
|
||||
@@ -184,9 +184,9 @@ export default function ListingsPage() {
|
||||
<Link key={listing.id} href={`/listings/${listing.id}`}>
|
||||
<Card className="h-full overflow-hidden transition-shadow hover:shadow-md">
|
||||
<div className="relative aspect-[4/3] bg-muted">
|
||||
{listing.property.media.length > 0 ? (
|
||||
{(listing.property.media?.length ?? 0) > 0 ? (
|
||||
<Image
|
||||
src={listing.property.media[0]?.url ?? ''}
|
||||
src={listing.property.media![0]?.url ?? ''}
|
||||
alt={listing.property.title}
|
||||
fill
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Building2, CheckCircle2, Home, MapPin, Users, type LucideIcon } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import * as React from 'react';
|
||||
import { PropertyCard } from '@/components/search/property-card';
|
||||
@@ -24,11 +25,11 @@ const DISTRICTS = [
|
||||
|
||||
type StatKey = 'listings' | 'users' | 'transactions' | 'provinces';
|
||||
|
||||
const STATS: { key: StatKey; value: string; icon: string }[] = [
|
||||
{ key: 'listings', value: '10,000+', icon: '🏠' },
|
||||
{ key: 'users', value: '50,000+', icon: '👥' },
|
||||
{ key: 'transactions', value: '2,000+', icon: '✅' },
|
||||
{ key: 'provinces', value: '63', icon: '📍' },
|
||||
const STATS: { key: StatKey; value: string; icon: LucideIcon }[] = [
|
||||
{ key: 'listings', value: '10,000+', icon: Home },
|
||||
{ key: 'users', value: '50,000+', icon: Users },
|
||||
{ key: 'transactions', value: '2,000+', icon: CheckCircle2 },
|
||||
{ key: 'provinces', value: '63', icon: MapPin },
|
||||
];
|
||||
|
||||
const PROPERTY_TYPE_KEYS = ['APARTMENT', 'HOUSE', 'VILLA', 'LAND', 'OFFICE', 'SHOPHOUSE'] as const;
|
||||
@@ -201,7 +202,7 @@ export default function LandingPage() {
|
||||
<Card className="group cursor-pointer overflow-hidden transition-shadow hover:shadow-md">
|
||||
<div className="aspect-[16/9] bg-gradient-to-br from-primary/10 to-primary/5">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<span className="text-3xl" aria-hidden="true">🏙️</span>
|
||||
<Building2 className="h-8 w-8 text-primary" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<CardContent className="p-3">
|
||||
@@ -231,7 +232,7 @@ export default function LandingPage() {
|
||||
key={stat.key}
|
||||
className="rounded-lg border bg-card p-6 text-center shadow-sm"
|
||||
>
|
||||
<span className="text-3xl" aria-hidden="true">{stat.icon}</span>
|
||||
<stat.icon className="h-8 w-8 text-primary" aria-hidden="true" />
|
||||
<p className="mt-2 text-3xl font-bold text-primary">{stat.value}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t(`stats.${stat.key}`)}</p>
|
||||
</div>
|
||||
|
||||
@@ -74,9 +74,9 @@ export function ComparisonTable({ listings, onRemove }: ComparisonTableProps) {
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{/* Image */}
|
||||
<div className="relative aspect-[4/3] w-full max-w-[200px] overflow-hidden rounded-md bg-muted">
|
||||
{listing.property.media.length > 0 ? (
|
||||
{(listing.property.media?.length ?? 0) > 0 ? (
|
||||
<Image
|
||||
src={listing.property.media[0]?.url ?? ''}
|
||||
src={listing.property.media![0]?.url ?? ''}
|
||||
alt={listing.property.title}
|
||||
fill
|
||||
sizes="200px"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { MessageCircle, Phone } from 'lucide-react';
|
||||
import { InquiryStatusBadge } from '@/components/inquiries/inquiry-row';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -80,7 +81,7 @@ export function InquiryDetailDialog({ inquiry, open, onOpenChange }: InquiryDeta
|
||||
href={`tel:${inquiry.phone ?? inquiry.userPhone}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
📞 Gọi điện
|
||||
<Phone className="h-4 w-4" aria-hidden="true" /> Gọi điện
|
||||
</a>
|
||||
<a
|
||||
href={`https://zalo.me/${(inquiry.phone ?? inquiry.userPhone).replace(/^0/, '84')}`}
|
||||
@@ -88,7 +89,7 @@ export function InquiryDetailDialog({ inquiry, open, onOpenChange }: InquiryDeta
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
💬 Zalo
|
||||
<MessageCircle className="h-4 w-4" aria-hidden="true" /> Zalo
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Mail, MessageCircle, Phone } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import { LeadStatusBadge } from '@/components/leads/lead-status-badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -151,14 +152,14 @@ export function LeadDetailDialog({ lead, open, onOpenChange }: LeadDetailDialogP
|
||||
href={`tel:${lead.phone}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
📞 Gọi điện
|
||||
<Phone className="h-4 w-4" aria-hidden="true" /> Gọi điện
|
||||
</a>
|
||||
{lead.email && (
|
||||
<a
|
||||
href={`mailto:${lead.email}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
✉️ Email
|
||||
<Mail className="h-4 w-4" aria-hidden="true" /> Email
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
@@ -167,7 +168,7 @@ export function LeadDetailDialog({ lead, open, onOpenChange }: LeadDetailDialogP
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
💬 Zalo
|
||||
<MessageCircle className="h-4 w-4" aria-hidden="true" /> Zalo
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -140,9 +140,9 @@ export function ListingMap({ listings, onMarkerClick, selectedListingId, classNa
|
||||
const container = document.createElement('div');
|
||||
container.style.fontFamily = 'system-ui,sans-serif';
|
||||
|
||||
if (listing.property.media.length > 0) {
|
||||
if ((listing.property.media?.length ?? 0) > 0) {
|
||||
const img = document.createElement('img');
|
||||
img.src = listing.property.media[0]!.url;
|
||||
img.src = listing.property.media![0]!.url;
|
||||
img.alt = listing.property.title;
|
||||
img.style.cssText = 'width:100%;height:96px;object-fit:cover;border-radius:6px;margin-bottom:8px;';
|
||||
container.appendChild(img);
|
||||
|
||||
@@ -32,9 +32,9 @@ export function PropertyCard({ listing, compact }: PropertyCardProps) {
|
||||
<Link href={`/listings/${listing.id}`}>
|
||||
<Card className="group h-full overflow-hidden transition-shadow hover:shadow-md">
|
||||
<div className={`relative bg-muted ${compact ? 'aspect-[16/10]' : 'aspect-[4/3]'}`}>
|
||||
{listing.property.media.length > 0 ? (
|
||||
{(listing.property.media?.length ?? 0) > 0 ? (
|
||||
<Image
|
||||
src={listing.property.media[0]?.url ?? ''}
|
||||
src={listing.property.media![0]?.url ?? ''}
|
||||
alt={`Ảnh bất động sản: ${listing.property.title}`}
|
||||
fill
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
@@ -55,10 +55,10 @@ export function PropertyCard({ listing, compact }: PropertyCardProps) {
|
||||
{propertyTypeLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
{listing.property.media.length > 1 && (
|
||||
{(listing.property.media?.length ?? 0) > 1 && (
|
||||
<div className="absolute bottom-2 right-2">
|
||||
<Badge variant="outline" className="bg-black/50 text-xs text-white border-none" aria-label={`${listing.property.media.length} ảnh`}>
|
||||
{listing.property.media.length} ảnh
|
||||
<Badge variant="outline" className="bg-black/50 text-xs text-white border-none" aria-label={`${listing.property.media!.length} ảnh`}>
|
||||
{listing.property.media!.length} ảnh
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { Globe } from 'lucide-react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import type { Locale } from '@/i18n/config';
|
||||
import { usePathname, useRouter } from '@/i18n/navigation';
|
||||
|
||||
const localeLabels: Record<Locale, string> = {
|
||||
vi: '🇻🇳 VI',
|
||||
en: '🇬🇧 EN',
|
||||
vi: 'VI',
|
||||
en: 'EN',
|
||||
};
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
@@ -28,6 +29,7 @@ export function LanguageSwitcher() {
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-md px-2.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
aria-label={`${t('label')}: ${t(locale)} → ${t(nextLocale)}`}
|
||||
>
|
||||
<Globe className="h-4 w-4" aria-hidden="true" />
|
||||
<span aria-hidden="true">{localeLabels[nextLocale]}</span>
|
||||
<span className="sr-only">{t(nextLocale)}</span>
|
||||
</button>
|
||||
|
||||
@@ -67,6 +67,7 @@ export interface ListingDetail {
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
media: PropertyMedia[];
|
||||
thumbnail?: string | null;
|
||||
};
|
||||
seller: {
|
||||
id: string;
|
||||
|
||||
756
prisma/seed.ts
756
prisma/seed.ts
@@ -1,282 +1,160 @@
|
||||
/**
|
||||
* GoodGo Platform — Comprehensive Seed
|
||||
*
|
||||
* Seeds ALL 27 models with realistic Vietnamese real estate data.
|
||||
* Idempotent: safe to run multiple times (uses upsert + ON CONFLICT).
|
||||
*
|
||||
* Default admin account:
|
||||
* Phone: 0876677771 | Email: hongochai10@icloud.com | Password: Velik@2026
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import {
|
||||
PrismaClient,
|
||||
UserRole,
|
||||
KYCStatus,
|
||||
PropertyType,
|
||||
TransactionType,
|
||||
ListingStatus,
|
||||
Direction,
|
||||
TransactionStatus,
|
||||
LeadStatus,
|
||||
PaymentProvider,
|
||||
PaymentStatus,
|
||||
PaymentType,
|
||||
OrderStatus,
|
||||
EscrowStatus,
|
||||
SubscriptionStatus,
|
||||
PlanTier,
|
||||
OAuthProvider,
|
||||
NotificationChannel,
|
||||
NotificationStatus,
|
||||
AdminAction,
|
||||
AuditTargetType,
|
||||
} from '@prisma/client';
|
||||
import pg from 'pg';
|
||||
import { importMarketData } from '../scripts/import-market-data';
|
||||
import { CITY_COORDINATES } from '../scripts/seed-districts';
|
||||
// bcrypt is installed in apps/api — resolve from there
|
||||
import bcrypt from 'bcrypt';
|
||||
import { seedPlans } from '../scripts/seed-plans';
|
||||
import { importMarketData } from '../scripts/import-market-data';
|
||||
|
||||
const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] });
|
||||
const adapter = new PrismaPg(pool);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
// =============================================================================
|
||||
// Sample coordinates for HCM districts
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
const _SAMPLE_LOCATIONS = CITY_COORDINATES['Hồ Chí Minh'];
|
||||
const DEFAULT_PASSWORD = 'Velik@2026';
|
||||
const BCRYPT_ROUNDS = 12;
|
||||
|
||||
const now = new Date();
|
||||
const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
|
||||
const oneMonthLater = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
const oneYearLater = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// =============================================================================
|
||||
// Seed functions
|
||||
// Phase 2 — Users & Identity
|
||||
// =============================================================================
|
||||
|
||||
// seedPlans is imported from scripts/seed-plans.ts
|
||||
async function seedUsers(passwordHash: string) {
|
||||
console.log('🔐 Seeding users...');
|
||||
|
||||
async function seedUsers() {
|
||||
console.log('Seeding sample users...');
|
||||
|
||||
// Use deterministic IDs so we can upsert by primary key (Prisma 7 requires
|
||||
// unique fields in `where`, and `phone` is no longer unique after the PII
|
||||
// encryption migration added `phoneHash` as the unique index instead).
|
||||
const SEED_IDS = {
|
||||
admin: 'seed-user-admin',
|
||||
agent1: 'seed-user-agent1',
|
||||
agent2: 'seed-user-agent2',
|
||||
buyer: 'seed-user-buyer',
|
||||
seller: 'seed-user-seller',
|
||||
} as const;
|
||||
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { id: SEED_IDS.admin },
|
||||
update: {},
|
||||
create: {
|
||||
id: SEED_IDS.admin,
|
||||
phone: '0900000001',
|
||||
email: 'admin@goodgo.vn',
|
||||
fullName: 'Admin GoodGo',
|
||||
role: UserRole.ADMIN,
|
||||
kycStatus: 'VERIFIED',
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
const agent1 = await prisma.user.upsert({
|
||||
where: { id: SEED_IDS.agent1 },
|
||||
update: {},
|
||||
create: {
|
||||
id: SEED_IDS.agent1,
|
||||
phone: '0900000002',
|
||||
email: 'agent.nguyen@goodgo.vn',
|
||||
fullName: 'Nguyễn Văn An',
|
||||
role: UserRole.AGENT,
|
||||
kycStatus: 'VERIFIED',
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
const agent2 = await prisma.user.upsert({
|
||||
where: { id: SEED_IDS.agent2 },
|
||||
update: {},
|
||||
create: {
|
||||
id: SEED_IDS.agent2,
|
||||
phone: '0900000003',
|
||||
email: 'agent.tran@goodgo.vn',
|
||||
fullName: 'Trần Thị Bình',
|
||||
role: UserRole.AGENT,
|
||||
kycStatus: 'VERIFIED',
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
const buyer = await prisma.user.upsert({
|
||||
where: { id: SEED_IDS.buyer },
|
||||
update: {},
|
||||
create: {
|
||||
id: SEED_IDS.buyer,
|
||||
phone: '0900000004',
|
||||
email: 'buyer.le@gmail.com',
|
||||
fullName: 'Lê Minh Cường',
|
||||
role: UserRole.BUYER,
|
||||
kycStatus: 'NONE',
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
const seller = await prisma.user.upsert({
|
||||
where: { id: SEED_IDS.seller },
|
||||
update: {},
|
||||
create: {
|
||||
id: SEED_IDS.seller,
|
||||
phone: '0900000005',
|
||||
email: 'seller.pham@gmail.com',
|
||||
fullName: 'Phạm Đức Dũng',
|
||||
role: UserRole.SELLER,
|
||||
kycStatus: 'VERIFIED',
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create agent profiles
|
||||
await prisma.agent.upsert({
|
||||
where: { userId: agent1.id },
|
||||
update: {},
|
||||
create: {
|
||||
userId: agent1.id,
|
||||
licenseNumber: 'BDS-2024-001',
|
||||
agency: 'GoodGo Premium Realty',
|
||||
qualityScore: 4.8,
|
||||
totalDeals: 127,
|
||||
responseTimeAvg: 15,
|
||||
bio: 'Chuyên viên bất động sản cao cấp Quận 1, Quận 7 với 10 năm kinh nghiệm.',
|
||||
serviceAreas: ['quan-1', 'quan-7', 'thu-duc'],
|
||||
isVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.agent.upsert({
|
||||
where: { userId: agent2.id },
|
||||
update: {},
|
||||
create: {
|
||||
userId: agent2.id,
|
||||
licenseNumber: 'BDS-2024-002',
|
||||
agency: 'Saigon Homes',
|
||||
qualityScore: 4.5,
|
||||
totalDeals: 89,
|
||||
responseTimeAvg: 20,
|
||||
bio: 'Chuyên gia bất động sản Bình Thạnh, Phú Nhuận. Tư vấn miễn phí.',
|
||||
serviceAreas: ['binh-thanh', 'phu-nhuan', 'go-vap'],
|
||||
isVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(' ✓ 5 users + 2 agent profiles seeded');
|
||||
return { admin, agent1, agent2, buyer, seller };
|
||||
}
|
||||
|
||||
async function seedProperties(users: Awaited<ReturnType<typeof seedUsers>>) {
|
||||
console.log('Seeding sample properties and listings...');
|
||||
|
||||
const sampleProperties = [
|
||||
{
|
||||
propertyType: PropertyType.APARTMENT,
|
||||
title: 'Căn hộ Vinhomes Central Park 3PN view sông',
|
||||
description:
|
||||
'Căn hộ 3 phòng ngủ tại Vinhomes Central Park, tầng cao view sông Sài Gòn. Full nội thất cao cấp, tiện ích đầy đủ.',
|
||||
address: '208 Nguyễn Hữu Cảnh',
|
||||
ward: 'Phường 22',
|
||||
district: 'Quận Bình Thạnh',
|
||||
city: 'Hồ Chí Minh',
|
||||
lat: 10.7942,
|
||||
lng: 106.7214,
|
||||
areaM2: 108,
|
||||
usableAreaM2: 95,
|
||||
bedrooms: 3,
|
||||
bathrooms: 2,
|
||||
floor: 25,
|
||||
totalFloors: 50,
|
||||
direction: Direction.SOUTHEAST,
|
||||
yearBuilt: 2018,
|
||||
legalStatus: 'Sổ hồng',
|
||||
priceVND: BigInt(8_500_000_000),
|
||||
transactionType: TransactionType.SALE,
|
||||
projectName: 'Vinhomes Central Park',
|
||||
},
|
||||
{
|
||||
propertyType: PropertyType.APARTMENT,
|
||||
title: 'Căn hộ The Sun Avenue 2PN cho thuê',
|
||||
description: 'Cho thuê căn hộ 2PN The Sun Avenue, nội thất đầy đủ, gần Metro, view đẹp.',
|
||||
address: '28 Mai Chí Thọ',
|
||||
ward: 'An Phú',
|
||||
district: 'Thủ Đức',
|
||||
city: 'Hồ Chí Minh',
|
||||
lat: 10.7696,
|
||||
lng: 106.7511,
|
||||
areaM2: 76,
|
||||
usableAreaM2: 68,
|
||||
bedrooms: 2,
|
||||
bathrooms: 2,
|
||||
floor: 15,
|
||||
totalFloors: 28,
|
||||
direction: Direction.NORTH,
|
||||
yearBuilt: 2020,
|
||||
legalStatus: 'Sổ hồng',
|
||||
priceVND: BigInt(15_000_000),
|
||||
transactionType: TransactionType.RENT,
|
||||
projectName: 'The Sun Avenue',
|
||||
},
|
||||
{
|
||||
propertyType: PropertyType.TOWNHOUSE,
|
||||
title: 'Nhà phố Thảo Điền 1 trệt 3 lầu',
|
||||
description:
|
||||
'Nhà phố khu compound an ninh Thảo Điền, 1 trệt 3 lầu, sân vườn rộng, gara ô tô.',
|
||||
address: '12 Nguyễn Văn Hưởng',
|
||||
ward: 'Thảo Điền',
|
||||
district: 'Thủ Đức',
|
||||
city: 'Hồ Chí Minh',
|
||||
lat: 10.8033,
|
||||
lng: 106.7391,
|
||||
areaM2: 200,
|
||||
usableAreaM2: 350,
|
||||
bedrooms: 4,
|
||||
bathrooms: 5,
|
||||
floors: 4,
|
||||
direction: Direction.SOUTH,
|
||||
yearBuilt: 2015,
|
||||
legalStatus: 'Sổ hồng',
|
||||
priceVND: BigInt(25_000_000_000),
|
||||
transactionType: TransactionType.SALE,
|
||||
projectName: null,
|
||||
},
|
||||
{
|
||||
propertyType: PropertyType.LAND,
|
||||
title: 'Đất nền Quận 7 gần Phú Mỹ Hưng',
|
||||
description: 'Đất nền thổ cư 100%, sổ riêng, hẻm ô tô, gần trung tâm Phú Mỹ Hưng.',
|
||||
address: '56 Huỳnh Tấn Phát',
|
||||
ward: 'Phú Thuận',
|
||||
district: 'Quận 7',
|
||||
city: 'Hồ Chí Minh',
|
||||
lat: 10.7312,
|
||||
lng: 106.7283,
|
||||
areaM2: 120,
|
||||
usableAreaM2: null,
|
||||
bedrooms: null,
|
||||
bathrooms: null,
|
||||
direction: Direction.EAST,
|
||||
yearBuilt: null,
|
||||
legalStatus: 'Sổ đỏ',
|
||||
priceVND: BigInt(12_000_000_000),
|
||||
transactionType: TransactionType.SALE,
|
||||
projectName: null,
|
||||
},
|
||||
{
|
||||
propertyType: PropertyType.OFFICE,
|
||||
title: 'Văn phòng cho thuê Quận 1 - 200m²',
|
||||
description:
|
||||
'Văn phòng hạng B tại trung tâm Quận 1, full nội thất, hệ thống PCCC, thang máy.',
|
||||
address: '123 Nguyễn Huệ',
|
||||
ward: 'Bến Nghé',
|
||||
district: 'Quận 1',
|
||||
city: 'Hồ Chí Minh',
|
||||
lat: 10.7731,
|
||||
lng: 106.703,
|
||||
areaM2: 200,
|
||||
usableAreaM2: 180,
|
||||
bedrooms: null,
|
||||
bathrooms: 2,
|
||||
floor: 8,
|
||||
totalFloors: 15,
|
||||
direction: Direction.WEST,
|
||||
yearBuilt: 2010,
|
||||
legalStatus: 'Sổ hồng',
|
||||
priceVND: BigInt(80_000_000),
|
||||
transactionType: TransactionType.RENT,
|
||||
projectName: null,
|
||||
},
|
||||
const users = [
|
||||
{ id: 'seed-admin-001', phone: '+84876677771', email: 'hongochai10@icloud.com', fullName: 'Hồ Ngọc Hải', role: UserRole.ADMIN, kycStatus: KYCStatus.VERIFIED, avatarUrl: 'https://ui-avatars.com/api/?name=Ho+Ngoc+Hai&background=dc2626&color=fff' },
|
||||
{ id: 'seed-agent-001', phone: '+84900000002', email: 'agent.nguyen@goodgo.vn', fullName: 'Nguyễn Văn An', role: UserRole.AGENT, kycStatus: KYCStatus.VERIFIED, avatarUrl: 'https://ui-avatars.com/api/?name=Nguyen+Van+An&background=2563eb&color=fff' },
|
||||
{ id: 'seed-agent-002', phone: '+84900000003', email: 'agent.tran@goodgo.vn', fullName: 'Trần Thị Bình', role: UserRole.AGENT, kycStatus: KYCStatus.VERIFIED, avatarUrl: 'https://ui-avatars.com/api/?name=Tran+Thi+Binh&background=7c3aed&color=fff' },
|
||||
{ id: 'seed-agent-003', phone: '+84900000006', email: 'agent.le.hong@goodgo.vn', fullName: 'Lê Thị Hồng', role: UserRole.AGENT, kycStatus: KYCStatus.VERIFIED, avatarUrl: 'https://ui-avatars.com/api/?name=Le+Thi+Hong&background=059669&color=fff' },
|
||||
{ id: 'seed-buyer-001', phone: '+84900000004', email: 'buyer.le@gmail.com', fullName: 'Lê Minh Cường', role: UserRole.BUYER, kycStatus: KYCStatus.NONE, avatarUrl: null },
|
||||
{ id: 'seed-buyer-002', phone: '+84900000007', email: 'buyer.hoang@gmail.com', fullName: 'Hoàng Thị Mai', role: UserRole.BUYER, kycStatus: KYCStatus.PENDING, avatarUrl: null },
|
||||
{ id: 'seed-seller-001', phone: '+84900000005', email: 'seller.pham@gmail.com', fullName: 'Phạm Đức Dũng', role: UserRole.SELLER, kycStatus: KYCStatus.VERIFIED, avatarUrl: 'https://ui-avatars.com/api/?name=Pham+Duc+Dung&background=d97706&color=fff' },
|
||||
{ id: 'seed-seller-002', phone: '+84900000008', email: 'seller.vo@gmail.com', fullName: 'Võ Thanh Tùng', role: UserRole.SELLER, kycStatus: KYCStatus.VERIFIED, avatarUrl: null },
|
||||
];
|
||||
|
||||
const agents = await prisma.agent.findMany();
|
||||
for (const u of users) {
|
||||
await prisma.user.upsert({
|
||||
where: { id: u.id },
|
||||
update: { passwordHash, email: u.email, fullName: u.fullName, role: u.role, kycStatus: u.kycStatus },
|
||||
create: {
|
||||
id: u.id, phone: u.phone, email: u.email, passwordHash,
|
||||
fullName: u.fullName, role: u.role, kycStatus: u.kycStatus,
|
||||
avatarUrl: u.avatarUrl, isActive: true, totpEnabled: false, totpBackupCodes: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < sampleProperties.length; i++) {
|
||||
const p = sampleProperties[i]!;
|
||||
const agent = agents[i % agents.length]!
|
||||
console.log(` ✓ ${users.length} users seeded (all with password: ${DEFAULT_PASSWORD})`);
|
||||
}
|
||||
|
||||
const _property = await prisma.$executeRaw`
|
||||
// =============================================================================
|
||||
// Phase 2b — Agents
|
||||
// =============================================================================
|
||||
|
||||
async function seedAgents() {
|
||||
console.log('👤 Seeding agent profiles...');
|
||||
|
||||
const agents = [
|
||||
{ id: 'seed-agentprofile-001', userId: 'seed-agent-001', licenseNumber: 'BDS-2024-001', agency: 'GoodGo Premium Realty', qualityScore: 4.8, totalDeals: 127, responseTimeAvg: 15, bio: 'Chuyên viên bất động sản cao cấp Quận 1, Quận 7 với 10 năm kinh nghiệm. Tư vấn miễn phí, hỗ trợ pháp lý toàn diện.', serviceAreas: ['quan-1', 'quan-7', 'thu-duc'], isVerified: true },
|
||||
{ id: 'seed-agentprofile-002', userId: 'seed-agent-002', licenseNumber: 'BDS-2024-002', agency: 'Saigon Homes', qualityScore: 4.5, totalDeals: 89, responseTimeAvg: 20, bio: 'Chuyên gia bất động sản Bình Thạnh, Phú Nhuận. Tư vấn miễn phí, cam kết giá tốt nhất.', serviceAreas: ['binh-thanh', 'phu-nhuan', 'go-vap'], isVerified: true },
|
||||
{ id: 'seed-agentprofile-003', userId: 'seed-agent-003', licenseNumber: 'BDS-2024-003', agency: 'Vietnam Land Trust', qualityScore: 4.2, totalDeals: 45, responseTimeAvg: 30, bio: 'Chuyên viên tư vấn đất nền, nhà phố Quận 7 và Nhà Bè. Hỗ trợ vay ngân hàng ưu đãi.', serviceAreas: ['quan-7', 'nha-be', 'binh-chanh'], isVerified: true },
|
||||
];
|
||||
|
||||
for (const a of agents) {
|
||||
await prisma.agent.upsert({ where: { userId: a.userId }, update: { qualityScore: a.qualityScore, totalDeals: a.totalDeals }, create: a });
|
||||
}
|
||||
console.log(` ✓ ${agents.length} agent profiles seeded`);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Phase 2c — OAuth Accounts
|
||||
// =============================================================================
|
||||
|
||||
async function seedOAuthAccounts() {
|
||||
console.log('🔗 Seeding OAuth accounts...');
|
||||
await prisma.oAuthAccount.upsert({
|
||||
where: { provider_providerUserId: { provider: OAuthProvider.GOOGLE, providerUserId: 'google-uid-hongochai10' } },
|
||||
update: {},
|
||||
create: { id: 'seed-oauth-001', userId: 'seed-admin-001', provider: OAuthProvider.GOOGLE, providerUserId: 'google-uid-hongochai10', profile: { name: 'Hồ Ngọc Hải', picture: 'https://lh3.googleusercontent.com/example' } },
|
||||
});
|
||||
console.log(' ✓ 1 OAuth account seeded');
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Phase 3 — Properties & Media
|
||||
// =============================================================================
|
||||
|
||||
async function seedProperties() {
|
||||
console.log('🏠 Seeding properties & media...');
|
||||
|
||||
interface PropSeed {
|
||||
id: string; propertyType: PropertyType; title: string; description: string;
|
||||
address: string; ward: string; district: string; city: string;
|
||||
lat: number; lng: number; areaM2: number;
|
||||
usableAreaM2: number | null; bedrooms: number | null; bathrooms: number | null;
|
||||
floors?: number | null; floor?: number | null; totalFloors?: number | null;
|
||||
direction: Direction | null; yearBuilt: number | null;
|
||||
legalStatus: string | null; projectName: string | null; amenities: string | null;
|
||||
}
|
||||
|
||||
const properties: PropSeed[] = [
|
||||
{ id: 'seed-prop-001', propertyType: PropertyType.APARTMENT, title: 'Căn hộ Vinhomes Central Park 3PN view sông Sài Gòn', description: 'Căn hộ 3 phòng ngủ tại Vinhomes Central Park, tầng cao view sông Sài Gòn tuyệt đẹp. Full nội thất cao cấp Châu Âu, tiện ích 5 sao.', address: '208 Nguyễn Hữu Cảnh', ward: 'Phường 22', district: 'Quận Bình Thạnh', city: 'Hồ Chí Minh', lat: 10.7942, lng: 106.7214, areaM2: 108, usableAreaM2: 95, bedrooms: 3, bathrooms: 2, floor: 25, totalFloors: 50, direction: Direction.SOUTHEAST, yearBuilt: 2018, legalStatus: 'Sổ hồng', projectName: 'Vinhomes Central Park', amenities: '["hồ bơi","gym","công viên","siêu thị"]' },
|
||||
{ id: 'seed-prop-002', propertyType: PropertyType.APARTMENT, title: 'Căn hộ The Sun Avenue 2PN cho thuê gần Metro', description: 'Cho thuê căn hộ 2PN The Sun Avenue, nội thất đầy đủ, gần tuyến Metro số 1.', address: '28 Mai Chí Thọ', ward: 'An Phú', district: 'Thủ Đức', city: 'Hồ Chí Minh', lat: 10.7696, lng: 106.7511, areaM2: 76, usableAreaM2: 68, bedrooms: 2, bathrooms: 2, floor: 15, totalFloors: 28, direction: Direction.NORTH, yearBuilt: 2020, legalStatus: 'Sổ hồng', projectName: 'The Sun Avenue', amenities: '["hồ bơi","gym","BBQ"]' },
|
||||
{ id: 'seed-prop-003', propertyType: PropertyType.TOWNHOUSE, title: 'Nhà phố Thảo Điền 1 trệt 3 lầu compound an ninh', description: 'Nhà phố khu compound an ninh Thảo Điền, sân vườn rộng, gara 2 ô tô.', address: '12 Nguyễn Văn Hưởng', ward: 'Thảo Điền', district: 'Thủ Đức', city: 'Hồ Chí Minh', lat: 10.8033, lng: 106.7391, areaM2: 200, usableAreaM2: 350, bedrooms: 4, bathrooms: 5, floors: 4, direction: Direction.SOUTH, yearBuilt: 2015, legalStatus: 'Sổ hồng', projectName: null, amenities: '["sân vườn","gara ô tô","bảo vệ 24/7"]' },
|
||||
{ id: 'seed-prop-004', propertyType: PropertyType.LAND, title: 'Đất nền Quận 7 gần Phú Mỹ Hưng thổ cư 100%', description: 'Đất nền thổ cư 100%, sổ riêng từng nền, hẻm ô tô 8m.', address: '56 Huỳnh Tấn Phát', ward: 'Phú Thuận', district: 'Quận 7', city: 'Hồ Chí Minh', lat: 10.7312, lng: 106.7283, areaM2: 120, usableAreaM2: null, bedrooms: null, bathrooms: null, direction: Direction.EAST, yearBuilt: null, legalStatus: 'Sổ đỏ', projectName: null, amenities: null },
|
||||
{ id: 'seed-prop-005', propertyType: PropertyType.OFFICE, title: 'Văn phòng cho thuê Quận 1 200m² trung tâm Nguyễn Huệ', description: 'Văn phòng hạng B+ trung tâm Quận 1, full nội thất, PCCC, thang máy.', address: '123 Nguyễn Huệ', ward: 'Bến Nghé', district: 'Quận 1', city: 'Hồ Chí Minh', lat: 10.7731, lng: 106.703, areaM2: 200, usableAreaM2: 180, bedrooms: null, bathrooms: 2, floor: 8, totalFloors: 15, direction: Direction.WEST, yearBuilt: 2010, legalStatus: 'Sổ hồng', projectName: null, amenities: '["thang máy","PCCC","bảo vệ 24/7"]' },
|
||||
{ id: 'seed-prop-006', propertyType: PropertyType.VILLA, title: 'Biệt thự Sala Đại Quang Minh view công viên', description: 'Biệt thự song lập Sala, 230m² đất, 1 trệt 2 lầu 1 áp mái, bể bơi riêng.', address: '10 Mai Chí Thọ', ward: 'An Lợi Đông', district: 'Thủ Đức', city: 'Hồ Chí Minh', lat: 10.7721, lng: 106.7432, areaM2: 230, usableAreaM2: 420, bedrooms: 5, bathrooms: 6, floors: 3, direction: Direction.NORTHEAST, yearBuilt: 2019, legalStatus: 'Sổ hồng', projectName: 'Sala Đại Quang Minh', amenities: '["bể bơi riêng","sân vườn","gara 3 ô tô"]' },
|
||||
{ id: 'seed-prop-007', propertyType: PropertyType.SHOPHOUSE, title: 'Shophouse Vạn Phúc City mặt tiền kinh doanh', description: 'Shophouse mặt tiền 30m khu đô thị Vạn Phúc City, 1 trệt 4 lầu.', address: '15 Nguyễn Thị Nhung', ward: 'Hiệp Bình Phước', district: 'Thủ Đức', city: 'Hồ Chí Minh', lat: 10.8345, lng: 106.7188, areaM2: 100, usableAreaM2: 400, bedrooms: 3, bathrooms: 4, floors: 5, direction: Direction.SOUTH, yearBuilt: 2022, legalStatus: 'Sổ hồng', projectName: 'Vạn Phúc City', amenities: '["mặt tiền kinh doanh","bãi đỗ xe"]' },
|
||||
{ id: 'seed-prop-008', propertyType: PropertyType.APARTMENT, title: 'Căn hộ Masteri Thảo Điền 1PN full nội thất', description: 'Căn hộ 1PN Masteri Thảo Điền, nội thất cao cấp, view hồ bơi.', address: '159 Xa lộ Hà Nội', ward: 'Thảo Điền', district: 'Thủ Đức', city: 'Hồ Chí Minh', lat: 10.8025, lng: 106.7415, areaM2: 50, usableAreaM2: 45, bedrooms: 1, bathrooms: 1, floor: 12, totalFloors: 40, direction: Direction.NORTHWEST, yearBuilt: 2017, legalStatus: 'Sổ hồng', projectName: 'Masteri Thảo Điền', amenities: '["hồ bơi","gym","sky lounge"]' },
|
||||
{ id: 'seed-prop-009', propertyType: PropertyType.APARTMENT, title: 'Căn hộ Midtown Phú Mỹ Hưng 2PN giá tốt', description: 'Căn hộ 2PN Midtown The Peak, Phú Mỹ Hưng. Nội thất cơ bản, view đẹp.', address: '12 Nguyễn Lương Bằng', ward: 'Tân Phú', district: 'Quận 7', city: 'Hồ Chí Minh', lat: 10.7285, lng: 106.7195, areaM2: 85, usableAreaM2: 78, bedrooms: 2, bathrooms: 2, floor: 18, totalFloors: 30, direction: Direction.EAST, yearBuilt: 2021, legalStatus: 'Sổ hồng', projectName: 'Midtown Phú Mỹ Hưng', amenities: '["hồ bơi","gym","công viên"]' },
|
||||
{ id: 'seed-prop-010', propertyType: PropertyType.TOWNHOUSE, title: 'Nhà phố Gò Vấp 1 trệt 2 lầu sổ hồng riêng', description: 'Nhà phố mới xây tại Gò Vấp, 1 trệt 2 lầu, sân thượng, hẻm xe hơi 6m.', address: '88 Nguyễn Oanh', ward: 'Phường 17', district: 'Quận Gò Vấp', city: 'Hồ Chí Minh', lat: 10.8352, lng: 106.6648, areaM2: 65, usableAreaM2: 150, bedrooms: 3, bathrooms: 3, floors: 3, direction: Direction.WEST, yearBuilt: 2024, legalStatus: 'Sổ hồng', projectName: null, amenities: '["sân thượng","ban công"]' },
|
||||
];
|
||||
|
||||
for (const p of properties) {
|
||||
await prisma.$executeRaw`
|
||||
INSERT INTO "Property" (
|
||||
"id", "propertyType", "title", "description", "address",
|
||||
"ward", "district", "city", "location",
|
||||
@@ -285,51 +163,388 @@ async function seedProperties(users: Awaited<ReturnType<typeof seedUsers>>) {
|
||||
"yearBuilt", "legalStatus", "amenities", "nearbyPOIs",
|
||||
"metroDistanceM", "projectName", "createdAt", "updatedAt"
|
||||
) VALUES (
|
||||
${`prop-${i + 1}`}, ${p.propertyType}::"PropertyType", ${p.title}, ${p.description}, ${p.address},
|
||||
${p.id}, ${p.propertyType}::"PropertyType", ${p.title}, ${p.description}, ${p.address},
|
||||
${p.ward}, ${p.district}, ${p.city}, ST_SetSRID(ST_MakePoint(${p.lng}, ${p.lat}), 4326),
|
||||
${p.areaM2}, ${p.usableAreaM2 ?? null}, ${p.bedrooms ?? null}, ${p.bathrooms ?? null},
|
||||
${p.floors ?? null}, ${p.floor ?? null}, ${p.totalFloors ?? null}, ${p.direction ?? null}::"Direction",
|
||||
${p.yearBuilt ?? null}, ${p.legalStatus ?? null}, ${null}, ${null},
|
||||
${p.yearBuilt ?? null}, ${p.legalStatus ?? null}, ${p.amenities ?? null}::jsonb, ${null}::jsonb,
|
||||
${null}, ${p.projectName ?? null}, NOW(), NOW()
|
||||
)
|
||||
ON CONFLICT ("id") DO NOTHING
|
||||
`;
|
||||
}
|
||||
|
||||
// Property Media — 2 images per property
|
||||
for (let i = 0; i < properties.length; i++) {
|
||||
const p = properties[i]!;
|
||||
for (let j = 0; j < 2; j++) {
|
||||
const mediaId = `seed-media-${i * 2 + j + 1}`;
|
||||
await prisma.propertyMedia.upsert({
|
||||
where: { id: mediaId },
|
||||
update: {},
|
||||
create: { id: mediaId, propertyId: p.id, url: `https://picsum.photos/seed/${p.id}-${j}/800/600`, type: 'image', order: j, caption: j === 0 ? 'Mặt tiền' : 'Nội thất' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` ✓ ${properties.length} properties + ${properties.length * 2} media seeded`);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Phase 4 — Listings
|
||||
// =============================================================================
|
||||
|
||||
async function seedListings() {
|
||||
console.log('📋 Seeding listings...');
|
||||
|
||||
const listings = [
|
||||
{ id: 'seed-listing-001', propertyId: 'seed-prop-001', agentId: 'seed-agentprofile-001', sellerId: 'seed-seller-001', transactionType: TransactionType.SALE, status: ListingStatus.ACTIVE, priceVND: BigInt(8_500_000_000), pricePerM2: 78703703.7 },
|
||||
{ id: 'seed-listing-002', propertyId: 'seed-prop-002', agentId: 'seed-agentprofile-001', sellerId: 'seed-seller-001', transactionType: TransactionType.RENT, status: ListingStatus.ACTIVE, priceVND: BigInt(15_000_000), pricePerM2: null, rentPriceMonthly: BigInt(15_000_000) },
|
||||
{ id: 'seed-listing-003', propertyId: 'seed-prop-003', agentId: 'seed-agentprofile-002', sellerId: 'seed-seller-001', transactionType: TransactionType.SALE, status: ListingStatus.ACTIVE, priceVND: BigInt(25_000_000_000), pricePerM2: 125000000.0 },
|
||||
{ id: 'seed-listing-004', propertyId: 'seed-prop-004', agentId: 'seed-agentprofile-003', sellerId: 'seed-seller-002', transactionType: TransactionType.SALE, status: ListingStatus.ACTIVE, priceVND: BigInt(12_000_000_000), pricePerM2: 100000000.0 },
|
||||
{ id: 'seed-listing-005', propertyId: 'seed-prop-005', agentId: 'seed-agentprofile-001', sellerId: 'seed-seller-001', transactionType: TransactionType.RENT, status: ListingStatus.ACTIVE, priceVND: BigInt(80_000_000), pricePerM2: null, rentPriceMonthly: BigInt(80_000_000) },
|
||||
{ id: 'seed-listing-006', propertyId: 'seed-prop-006', agentId: 'seed-agentprofile-002', sellerId: 'seed-seller-002', transactionType: TransactionType.SALE, status: ListingStatus.SOLD, priceVND: BigInt(35_000_000_000), pricePerM2: 152173913.0 },
|
||||
{ id: 'seed-listing-007', propertyId: 'seed-prop-007', agentId: 'seed-agentprofile-003', sellerId: 'seed-seller-002', transactionType: TransactionType.SALE, status: ListingStatus.PENDING_REVIEW, priceVND: BigInt(18_000_000_000), pricePerM2: 180000000.0 },
|
||||
{ id: 'seed-listing-008', propertyId: 'seed-prop-008', agentId: 'seed-agentprofile-001', sellerId: 'seed-seller-001', transactionType: TransactionType.RENT, status: ListingStatus.ACTIVE, priceVND: BigInt(12_000_000), pricePerM2: null, rentPriceMonthly: BigInt(12_000_000) },
|
||||
{ id: 'seed-listing-009', propertyId: 'seed-prop-009', agentId: 'seed-agentprofile-002', sellerId: 'seed-seller-001', transactionType: TransactionType.RENT, status: ListingStatus.ACTIVE, priceVND: BigInt(20_000_000), pricePerM2: null, rentPriceMonthly: BigInt(20_000_000) },
|
||||
{ id: 'seed-listing-010', propertyId: 'seed-prop-010', agentId: 'seed-agentprofile-003', sellerId: 'seed-seller-002', transactionType: TransactionType.SALE, status: ListingStatus.ACTIVE, priceVND: BigInt(5_200_000_000), pricePerM2: 80000000.0 },
|
||||
];
|
||||
|
||||
for (const l of listings) {
|
||||
const isPublished = l.status === ListingStatus.ACTIVE || l.status === ListingStatus.SOLD;
|
||||
await prisma.listing.upsert({
|
||||
where: { id: `listing-${i + 1}` },
|
||||
where: { id: l.id },
|
||||
update: {},
|
||||
create: {
|
||||
id: `listing-${i + 1}`,
|
||||
propertyId: `prop-${i + 1}`,
|
||||
agentId: agent.id,
|
||||
sellerId: users.seller.id,
|
||||
transactionType: p.transactionType,
|
||||
status: ListingStatus.ACTIVE,
|
||||
priceVND: p.priceVND,
|
||||
pricePerM2: Number(p.priceVND) / p.areaM2,
|
||||
publishedAt: new Date(),
|
||||
...l, commissionPct: 2.0,
|
||||
viewCount: Math.floor(Math.random() * 500) + 10,
|
||||
saveCount: Math.floor(Math.random() * 50),
|
||||
inquiryCount: Math.floor(Math.random() * 20),
|
||||
publishedAt: isPublished ? oneWeekAgo : null,
|
||||
expiresAt: l.status === ListingStatus.ACTIVE ? oneYearLater : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` ✓ ${sampleProperties.length} properties + listings seeded`);
|
||||
console.log(` ✓ ${listings.length} listings seeded`);
|
||||
}
|
||||
|
||||
// seedMarketIndex is now handled by importMarketData from scripts/import-market-data.ts
|
||||
// =============================================================================
|
||||
// Phase 5 — Subscriptions
|
||||
// =============================================================================
|
||||
|
||||
async function seedSubscriptions() {
|
||||
console.log('💎 Seeding subscriptions...');
|
||||
|
||||
const plans = await prisma.plan.findMany();
|
||||
const planMap = Object.fromEntries(plans.map(p => [p.tier, p.id]));
|
||||
|
||||
const subs = [
|
||||
{ id: 'seed-sub-001', userId: 'seed-admin-001', tier: PlanTier.ENTERPRISE },
|
||||
{ id: 'seed-sub-002', userId: 'seed-agent-001', tier: PlanTier.AGENT_PRO },
|
||||
{ id: 'seed-sub-003', userId: 'seed-agent-002', tier: PlanTier.AGENT_PRO },
|
||||
{ id: 'seed-sub-004', userId: 'seed-agent-003', tier: PlanTier.AGENT_PRO },
|
||||
{ id: 'seed-sub-005', userId: 'seed-buyer-001', tier: PlanTier.FREE },
|
||||
{ id: 'seed-sub-006', userId: 'seed-buyer-002', tier: PlanTier.INVESTOR },
|
||||
];
|
||||
|
||||
for (const s of subs) {
|
||||
await prisma.subscription.upsert({
|
||||
where: { userId: s.userId },
|
||||
update: {},
|
||||
create: { id: s.id, userId: s.userId, planId: planMap[s.tier]!, status: SubscriptionStatus.ACTIVE, currentPeriodStart: oneMonthAgo, currentPeriodEnd: oneMonthLater },
|
||||
});
|
||||
}
|
||||
|
||||
const usageRecords = [
|
||||
{ id: 'seed-usage-001', subscriptionId: 'seed-sub-002', metric: 'listings_created', count: 12, periodStart: oneMonthAgo, periodEnd: oneMonthLater },
|
||||
{ id: 'seed-usage-002', subscriptionId: 'seed-sub-002', metric: 'analytics_queries', count: 45, periodStart: oneMonthAgo, periodEnd: oneMonthLater },
|
||||
{ id: 'seed-usage-003', subscriptionId: 'seed-sub-002', metric: 'media_uploads', count: 38, periodStart: oneMonthAgo, periodEnd: oneMonthLater },
|
||||
{ id: 'seed-usage-004', subscriptionId: 'seed-sub-003', metric: 'listings_created', count: 8, periodStart: oneMonthAgo, periodEnd: oneMonthLater },
|
||||
{ id: 'seed-usage-005', subscriptionId: 'seed-sub-003', metric: 'analytics_queries', count: 22, periodStart: oneMonthAgo, periodEnd: oneMonthLater },
|
||||
{ id: 'seed-usage-006', subscriptionId: 'seed-sub-004', metric: 'listings_created', count: 5, periodStart: oneMonthAgo, periodEnd: oneMonthLater },
|
||||
{ id: 'seed-usage-007', subscriptionId: 'seed-sub-005', metric: 'listings_created', count: 2, periodStart: oneMonthAgo, periodEnd: oneMonthLater },
|
||||
{ id: 'seed-usage-008', subscriptionId: 'seed-sub-006', metric: 'analytics_queries', count: 150, periodStart: oneMonthAgo, periodEnd: oneMonthLater },
|
||||
];
|
||||
|
||||
for (const u of usageRecords) {
|
||||
await prisma.usageRecord.upsert({ where: { id: u.id }, update: {}, create: u });
|
||||
}
|
||||
console.log(` ✓ ${subs.length} subscriptions + ${usageRecords.length} usage records seeded`);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Phase 6 — Inquiries, Transactions, Leads
|
||||
// =============================================================================
|
||||
|
||||
async function seedInquiries() {
|
||||
console.log('💬 Seeding inquiries...');
|
||||
const inquiries = [
|
||||
{ id: 'seed-inq-001', listingId: 'seed-listing-001', userId: 'seed-buyer-001', message: 'Cho em hỏi căn hộ này còn không ạ? Em muốn đặt lịch xem nhà cuối tuần.', phone: '+84900000004', isRead: true },
|
||||
{ id: 'seed-inq-002', listingId: 'seed-listing-001', userId: 'seed-buyer-002', message: 'Giá cuối là bao nhiêu? Có thương lượng được không ạ?', phone: '+84900000007', isRead: true },
|
||||
{ id: 'seed-inq-003', listingId: 'seed-listing-003', userId: 'seed-buyer-001', message: 'Em quan tâm nhà phố Thảo Điền. Cho xem giấy tờ pháp lý.', isRead: false },
|
||||
{ id: 'seed-inq-004', listingId: 'seed-listing-004', userId: 'seed-buyer-002', message: 'Đất nền có quy hoạch gì không? Được xây dựng luôn không ạ?', phone: '+84900000007', isRead: false },
|
||||
{ id: 'seed-inq-005', listingId: 'seed-listing-005', userId: 'seed-buyer-001', message: 'Văn phòng hợp đồng tối thiểu bao lâu? Miễn phí tháng đầu không?', isRead: true },
|
||||
{ id: 'seed-inq-006', listingId: 'seed-listing-008', userId: 'seed-buyer-002', message: 'Masteri cho thuê có bao gồm phí quản lý không ạ?', phone: '+84900000007', isRead: false },
|
||||
];
|
||||
for (const inq of inquiries) {
|
||||
await prisma.inquiry.upsert({ where: { id: inq.id }, update: {}, create: { ...inq, createdAt: threeDaysAgo } });
|
||||
}
|
||||
console.log(` ✓ ${inquiries.length} inquiries seeded`);
|
||||
}
|
||||
|
||||
async function seedTransactions() {
|
||||
console.log('🔄 Seeding transactions...');
|
||||
const transactions = [
|
||||
{ id: 'seed-tx-001', listingId: 'seed-listing-001', buyerId: 'seed-buyer-001', status: TransactionStatus.VIEWING_SCHEDULED, timeline: { viewingDate: oneWeekAgo.toISOString() } },
|
||||
{ id: 'seed-tx-002', listingId: 'seed-listing-003', buyerId: 'seed-buyer-001', status: TransactionStatus.OFFER_MADE, agreedPrice: BigInt(24_000_000_000), timeline: { viewingDate: oneMonthAgo.toISOString(), offerDate: oneWeekAgo.toISOString() } },
|
||||
{ id: 'seed-tx-003', listingId: 'seed-listing-006', buyerId: 'seed-buyer-002', status: TransactionStatus.COMPLETED, agreedPrice: BigInt(34_500_000_000), depositAmount: BigInt(3_450_000_000), timeline: { completedDate: threeDaysAgo.toISOString() }, contractUrl: 'https://storage.goodgo.vn/contracts/seed-tx-003.pdf' },
|
||||
{ id: 'seed-tx-004', listingId: 'seed-listing-004', buyerId: 'seed-buyer-002', status: TransactionStatus.INQUIRY },
|
||||
];
|
||||
for (const t of transactions) {
|
||||
await prisma.transaction.upsert({ where: { id: t.id }, update: {}, create: t });
|
||||
}
|
||||
console.log(` ✓ ${transactions.length} transactions seeded`);
|
||||
}
|
||||
|
||||
async function seedLeads() {
|
||||
console.log('🎯 Seeding leads...');
|
||||
const leads = [
|
||||
{ id: 'seed-lead-001', agentId: 'seed-agentprofile-001', name: 'Nguyễn Thanh Long', phone: '+84912345001', email: 'long.nguyen@email.com', source: 'website', score: 0.85, status: LeadStatus.QUALIFIED, notes: { history: ['Quan tâm căn hộ Quận 1', 'Budget 8-10 tỷ'] } },
|
||||
{ id: 'seed-lead-002', agentId: 'seed-agentprofile-001', name: 'Trần Minh Đức', phone: '+84912345002', source: 'referral', score: 0.92, status: LeadStatus.NEGOTIATING, notes: { history: ['Muốn mua nhà phố Thảo Điền'] } },
|
||||
{ id: 'seed-lead-003', agentId: 'seed-agentprofile-002', name: 'Phạm Hồng Nhung', phone: '+84912345003', email: 'nhung.pham@email.com', source: 'website', score: 0.65, status: LeadStatus.CONTACTED, notes: { history: ['Tìm căn hộ cho thuê Bình Thạnh'] } },
|
||||
{ id: 'seed-lead-004', agentId: 'seed-agentprofile-002', name: 'Lê Quang Vinh', phone: '+84912345004', source: 'phone', score: 0.45, status: LeadStatus.NEW, notes: null },
|
||||
{ id: 'seed-lead-005', agentId: 'seed-agentprofile-003', name: 'Đỗ Thị Lan', phone: '+84912345005', email: 'lan.do@email.com', source: 'website', score: 0.78, status: LeadStatus.CONVERTED, notes: { history: ['Đã mua đất nền Quận 7'] } },
|
||||
{ id: 'seed-lead-006', agentId: 'seed-agentprofile-003', name: 'Bùi Văn Hùng', phone: '+84912345006', source: 'facebook', score: 0.3, status: LeadStatus.LOST, notes: { history: ['Không liên lạc được'] } },
|
||||
];
|
||||
for (const l of leads) {
|
||||
await prisma.lead.upsert({ where: { id: l.id }, update: {}, create: l });
|
||||
}
|
||||
console.log(` ✓ ${leads.length} leads seeded`);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Phase 7 — Orders, Escrow, Payments
|
||||
// =============================================================================
|
||||
|
||||
async function seedOrdersAndPayments() {
|
||||
console.log('💳 Seeding orders, escrow & payments...');
|
||||
|
||||
const orders = [
|
||||
{ id: 'seed-order-001', buyerId: 'seed-buyer-002', sellerId: 'seed-seller-002', listingId: 'seed-listing-006', status: OrderStatus.COMPLETED, amountVND: BigInt(34_500_000_000), platformFeeVND: BigInt(1_725_000_000), sellerPayoutVND: BigInt(32_775_000_000) },
|
||||
{ id: 'seed-order-002', buyerId: 'seed-buyer-001', sellerId: 'seed-seller-001', listingId: 'seed-listing-001', status: OrderStatus.ESCROW_HELD, amountVND: BigInt(8_500_000_000), platformFeeVND: BigInt(425_000_000), sellerPayoutVND: BigInt(8_075_000_000) },
|
||||
{ id: 'seed-order-003', buyerId: 'seed-buyer-001', sellerId: 'seed-seller-002', listingId: 'seed-listing-010', status: OrderStatus.PAYMENT_PENDING, amountVND: BigInt(5_200_000_000), platformFeeVND: BigInt(260_000_000), sellerPayoutVND: BigInt(4_940_000_000) },
|
||||
];
|
||||
for (const o of orders) {
|
||||
await prisma.order.upsert({ where: { id: o.id }, update: {}, create: o });
|
||||
}
|
||||
|
||||
const escrows = [
|
||||
{ id: 'seed-escrow-001', orderId: 'seed-order-001', amountVND: BigInt(34_500_000_000), feeVND: BigInt(345_000_000), status: EscrowStatus.RELEASED, heldAt: oneWeekAgo, releasedAt: threeDaysAgo },
|
||||
{ id: 'seed-escrow-002', orderId: 'seed-order-002', amountVND: BigInt(8_500_000_000), feeVND: BigInt(85_000_000), status: EscrowStatus.HELD, heldAt: threeDaysAgo },
|
||||
];
|
||||
for (const e of escrows) {
|
||||
await prisma.escrow.upsert({ where: { orderId: e.orderId }, update: {}, create: e });
|
||||
}
|
||||
|
||||
const payments = [
|
||||
{ id: 'seed-pay-001', userId: 'seed-buyer-002', orderId: 'seed-order-001', provider: PaymentProvider.VNPAY, type: PaymentType.DEPOSIT, amountVND: BigInt(3_450_000_000), status: PaymentStatus.COMPLETED, providerTxId: 'VNP-20260401-001' },
|
||||
{ id: 'seed-pay-002', userId: 'seed-buyer-002', orderId: 'seed-order-001', provider: PaymentProvider.BANK_TRANSFER, type: PaymentType.AUCTION_PAYMENT, amountVND: BigInt(31_050_000_000), status: PaymentStatus.COMPLETED, providerTxId: 'BANK-20260405-001' },
|
||||
{ id: 'seed-pay-003', userId: 'seed-buyer-001', orderId: 'seed-order-002', provider: PaymentProvider.MOMO, type: PaymentType.DEPOSIT, amountVND: BigInt(850_000_000), status: PaymentStatus.COMPLETED, providerTxId: 'MOMO-20260410-001' },
|
||||
{ id: 'seed-pay-004', userId: 'seed-agent-001', provider: PaymentProvider.ZALOPAY, type: PaymentType.SUBSCRIPTION, amountVND: BigInt(499_000), status: PaymentStatus.COMPLETED, providerTxId: 'ZALO-20260301-001' },
|
||||
{ id: 'seed-pay-005', userId: 'seed-agent-002', provider: PaymentProvider.VNPAY, type: PaymentType.SUBSCRIPTION, amountVND: BigInt(499_000), status: PaymentStatus.COMPLETED, providerTxId: 'VNP-20260301-002' },
|
||||
{ id: 'seed-pay-006', userId: 'seed-seller-001', provider: PaymentProvider.VNPAY, type: PaymentType.FEATURED_LISTING, amountVND: BigInt(200_000), status: PaymentStatus.PENDING, providerTxId: null },
|
||||
];
|
||||
for (const p of payments) {
|
||||
await prisma.payment.upsert({ where: { id: p.id }, update: {}, create: p });
|
||||
}
|
||||
console.log(` ✓ ${orders.length} orders + ${escrows.length} escrows + ${payments.length} payments seeded`);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Phase 8 — Reviews, Valuations, Saved Searches
|
||||
// =============================================================================
|
||||
|
||||
async function seedReviews() {
|
||||
console.log('⭐ Seeding reviews...');
|
||||
const reviews = [
|
||||
{ id: 'seed-review-001', userId: 'seed-buyer-001', targetType: 'agent', targetId: 'seed-agentprofile-001', rating: 5, comment: 'Anh An tư vấn rất nhiệt tình, chuyên nghiệp!' },
|
||||
{ id: 'seed-review-002', userId: 'seed-buyer-002', targetType: 'agent', targetId: 'seed-agentprofile-001', rating: 4, comment: 'Dịch vụ tốt, phản hồi nhanh.' },
|
||||
{ id: 'seed-review-003', userId: 'seed-buyer-001', targetType: 'agent', targetId: 'seed-agentprofile-002', rating: 5, comment: 'Chị Bình am hiểu thị trường lắm. Rất hài lòng!' },
|
||||
{ id: 'seed-review-004', userId: 'seed-buyer-002', targetType: 'agent', targetId: 'seed-agentprofile-003', rating: 3, comment: 'Tư vấn ổn nhưng phản hồi hơi chậm.' },
|
||||
{ id: 'seed-review-005', userId: 'seed-buyer-001', targetType: 'property', targetId: 'seed-prop-001', rating: 5, comment: 'Căn hộ tuyệt vời, view sông rất đẹp.' },
|
||||
{ id: 'seed-review-006', userId: 'seed-buyer-002', targetType: 'property', targetId: 'seed-prop-002', rating: 4, comment: 'Vị trí tốt gần Metro, nội thất cơ bản nhưng sạch sẽ.' },
|
||||
{ id: 'seed-review-007', userId: 'seed-buyer-001', targetType: 'property', targetId: 'seed-prop-006', rating: 5, comment: 'Biệt thự Sala thực sự đẳng cấp.' },
|
||||
{ id: 'seed-review-008', userId: 'seed-buyer-002', targetType: 'seller', targetId: 'seed-seller-001', rating: 4, comment: 'Anh Dũng rất thẳng thắn, giấy tờ rõ ràng.' },
|
||||
];
|
||||
for (const r of reviews) {
|
||||
await prisma.review.upsert({ where: { id: r.id }, update: {}, create: r });
|
||||
}
|
||||
console.log(` ✓ ${reviews.length} reviews seeded`);
|
||||
}
|
||||
|
||||
async function seedValuations() {
|
||||
console.log('📊 Seeding valuations...');
|
||||
const valuations = [
|
||||
{ id: 'seed-val-001', propertyId: 'seed-prop-001', estimatedPrice: BigInt(8_800_000_000), confidence: 0.92, pricePerM2: 81481481.5, comparables: { ids: ['c1', 'c2', 'c3'], avgPrice: 8600000000 }, features: { location: 0.3, size: 0.2, floor: 0.15, view: 0.2, project: 0.15 }, modelVersion: 'goodgo-avm-v2.1' },
|
||||
{ id: 'seed-val-002', propertyId: 'seed-prop-003', estimatedPrice: BigInt(26_000_000_000), confidence: 0.85, pricePerM2: 130000000.0, comparables: { ids: ['c4', 'c5'], avgPrice: 25000000000 }, features: { location: 0.35, size: 0.25, compound: 0.2, condition: 0.2 }, modelVersion: 'goodgo-avm-v2.1' },
|
||||
{ id: 'seed-val-003', propertyId: 'seed-prop-004', estimatedPrice: BigInt(11_500_000_000), confidence: 0.78, pricePerM2: 95833333.3, comparables: { ids: ['c6', 'c7', 'c8'], avgPrice: 11200000000 }, features: { location: 0.4, size: 0.3, legal: 0.3 }, modelVersion: 'goodgo-avm-v2.1' },
|
||||
{ id: 'seed-val-004', propertyId: 'seed-prop-006', estimatedPrice: BigInt(36_000_000_000), confidence: 0.88, pricePerM2: 156521739.1, comparables: { ids: ['c9', 'c10'], avgPrice: 35000000000 }, features: { location: 0.3, project: 0.25, size: 0.2, amenities: 0.25 }, modelVersion: 'goodgo-avm-v2.1' },
|
||||
{ id: 'seed-val-005', propertyId: 'seed-prop-010', estimatedPrice: BigInt(5_500_000_000), confidence: 0.81, pricePerM2: 84615384.6, comparables: { ids: ['c11', 'c12'], avgPrice: 5300000000 }, features: { location: 0.3, size: 0.25, newBuild: 0.25, legal: 0.2 }, modelVersion: 'goodgo-avm-v2.1' },
|
||||
];
|
||||
for (const v of valuations) {
|
||||
await prisma.valuation.upsert({ where: { id: v.id }, update: {}, create: v });
|
||||
}
|
||||
console.log(` ✓ ${valuations.length} valuations seeded`);
|
||||
}
|
||||
|
||||
async function seedSavedSearches() {
|
||||
console.log('🔍 Seeding saved searches...');
|
||||
const searches = [
|
||||
{ id: 'seed-search-001', userId: 'seed-buyer-001', name: 'Căn hộ Quận 1 dưới 10 tỷ', filters: { district: 'Quận 1', propertyType: 'APARTMENT', priceMax: 10000000000, bedrooms: 2 }, alertEnabled: true },
|
||||
{ id: 'seed-search-002', userId: 'seed-buyer-001', name: 'Nhà phố Thủ Đức', filters: { district: 'Thủ Đức', propertyType: 'TOWNHOUSE', transactionType: 'SALE' }, alertEnabled: true },
|
||||
{ id: 'seed-search-003', userId: 'seed-buyer-002', name: 'Cho thuê gần Metro', filters: { transactionType: 'RENT', priceMax: 20000000, metroDistanceMax: 1000 }, alertEnabled: true },
|
||||
{ id: 'seed-search-004', userId: 'seed-buyer-002', name: 'Đất nền Quận 7 đầu tư', filters: { district: 'Quận 7', propertyType: 'LAND', priceMax: 15000000000 }, alertEnabled: false },
|
||||
];
|
||||
for (const s of searches) {
|
||||
await prisma.savedSearch.upsert({ where: { id: s.id }, update: {}, create: s });
|
||||
}
|
||||
console.log(` ✓ ${searches.length} saved searches seeded`);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Phase 9 — Notifications & Audit
|
||||
// =============================================================================
|
||||
|
||||
async function seedNotifications() {
|
||||
console.log('🔔 Seeding notifications...');
|
||||
const logs = [
|
||||
{ id: 'seed-notif-001', userId: 'seed-buyer-001', channel: NotificationChannel.EMAIL, templateKey: 'new_listing_match', subject: 'Có căn hộ phù hợp tiêu chí của bạn', body: 'Chào anh Cường, có 3 căn hộ mới tại Quận 1 phù hợp.', status: NotificationStatus.DELIVERED, sentAt: oneWeekAgo },
|
||||
{ id: 'seed-notif-002', userId: 'seed-buyer-001', channel: NotificationChannel.PUSH, templateKey: 'inquiry_replied', body: 'Agent An đã phản hồi câu hỏi về Vinhomes Central Park.', status: NotificationStatus.DELIVERED, sentAt: threeDaysAgo, readAt: threeDaysAgo },
|
||||
{ id: 'seed-notif-003', userId: 'seed-agent-001', channel: NotificationChannel.EMAIL, templateKey: 'new_inquiry', subject: 'Câu hỏi mới về tin đăng', body: 'Lê Minh Cường đã gửi câu hỏi về Vinhomes Central Park 3PN.', status: NotificationStatus.DELIVERED, sentAt: threeDaysAgo },
|
||||
{ id: 'seed-notif-004', userId: 'seed-agent-001', channel: NotificationChannel.SMS, templateKey: 'lead_new', body: 'GoodGo: Lead mới từ Nguyễn Thanh Long. Budget 8-10 tỷ.', status: NotificationStatus.SENT, sentAt: oneWeekAgo },
|
||||
{ id: 'seed-notif-005', userId: 'seed-seller-001', channel: NotificationChannel.EMAIL, templateKey: 'listing_approved', subject: 'Tin đăng đã được duyệt', body: 'Tin "Căn hộ Vinhomes Central Park 3PN" đã duyệt.', status: NotificationStatus.DELIVERED, sentAt: oneWeekAgo },
|
||||
{ id: 'seed-notif-006', userId: 'seed-buyer-002', channel: NotificationChannel.EMAIL, templateKey: 'payment_success', subject: 'Thanh toán thành công', body: 'Thanh toán 3.450.000.000 VND thành công.', status: NotificationStatus.DELIVERED, sentAt: oneWeekAgo },
|
||||
{ id: 'seed-notif-007', userId: 'seed-agent-002', channel: NotificationChannel.PUSH, templateKey: 'subscription_renew', body: 'Gói Agent Pro sắp hết hạn. Gia hạn ngay!', status: NotificationStatus.DELIVERED, sentAt: threeDaysAgo },
|
||||
{ id: 'seed-notif-008', userId: 'seed-admin-001', channel: NotificationChannel.EMAIL, templateKey: 'kyc_pending', subject: '2 yêu cầu KYC mới', body: 'Có 2 yêu cầu KYC đang chờ duyệt.', status: NotificationStatus.DELIVERED, sentAt: oneWeekAgo, readAt: oneWeekAgo },
|
||||
{ id: 'seed-notif-009', userId: 'seed-buyer-001', channel: NotificationChannel.ZALO_OA, templateKey: 'viewing_reminder', body: 'Nhắc nhở: Lịch xem nhà Vinhomes Central Park 14:00 ngày mai.', status: NotificationStatus.PENDING },
|
||||
{ id: 'seed-notif-010', userId: 'seed-seller-002', channel: NotificationChannel.EMAIL, templateKey: 'escrow_released', subject: 'Escrow đã giải ngân', body: 'Escrow 34.5 tỷ đã giải ngân. Phí nền tảng: 1.725 tỷ.', status: NotificationStatus.DELIVERED, sentAt: threeDaysAgo },
|
||||
];
|
||||
for (const n of logs) {
|
||||
await prisma.notificationLog.upsert({ where: { id: n.id }, update: {}, create: n });
|
||||
}
|
||||
|
||||
const prefs = [
|
||||
{ id: 'seed-pref-001', userId: 'seed-buyer-001', channel: NotificationChannel.EMAIL, eventType: 'new_listing_match', enabled: true },
|
||||
{ id: 'seed-pref-002', userId: 'seed-buyer-001', channel: NotificationChannel.PUSH, eventType: 'inquiry_replied', enabled: true },
|
||||
{ id: 'seed-pref-003', userId: 'seed-buyer-001', channel: NotificationChannel.SMS, eventType: 'viewing_reminder', enabled: false },
|
||||
{ id: 'seed-pref-004', userId: 'seed-agent-001', channel: NotificationChannel.EMAIL, eventType: 'new_inquiry', enabled: true },
|
||||
{ id: 'seed-pref-005', userId: 'seed-agent-001', channel: NotificationChannel.SMS, eventType: 'lead_new', enabled: true },
|
||||
{ id: 'seed-pref-006', userId: 'seed-seller-001', channel: NotificationChannel.EMAIL, eventType: 'listing_approved', enabled: true },
|
||||
];
|
||||
for (const p of prefs) {
|
||||
await prisma.notificationPreference.upsert({ where: { userId_channel_eventType: { userId: p.userId, channel: p.channel, eventType: p.eventType } }, update: {}, create: p });
|
||||
}
|
||||
console.log(` ✓ ${logs.length} notifications + ${prefs.length} preferences seeded`);
|
||||
}
|
||||
|
||||
async function seedAuditLogs() {
|
||||
console.log('📝 Seeding admin audit logs...');
|
||||
const logs = [
|
||||
{ id: 'seed-audit-001', action: AdminAction.LISTING_APPROVED, actorId: 'seed-admin-001', targetId: 'seed-listing-001', targetType: AuditTargetType.LISTING, metadata: { reason: 'Đầy đủ thông tin, ảnh chất lượng tốt' }, ipAddress: '103.12.45.67' },
|
||||
{ id: 'seed-audit-002', action: AdminAction.LISTING_APPROVED, actorId: 'seed-admin-001', targetId: 'seed-listing-003', targetType: AuditTargetType.LISTING, metadata: { reason: 'Đã xác minh sổ hồng' }, ipAddress: '103.12.45.67' },
|
||||
{ id: 'seed-audit-003', action: AdminAction.KYC_APPROVED, actorId: 'seed-admin-001', targetId: 'seed-agent-001', targetType: AuditTargetType.USER, metadata: { documents: ['CCCD', 'Chứng chỉ BDS'] }, ipAddress: '103.12.45.67' },
|
||||
{ id: 'seed-audit-004', action: AdminAction.KYC_APPROVED, actorId: 'seed-admin-001', targetId: 'seed-seller-001', targetType: AuditTargetType.USER, metadata: { documents: ['CCCD'] }, ipAddress: '103.12.45.67' },
|
||||
{ id: 'seed-audit-005', action: AdminAction.LISTING_REJECTED, actorId: 'seed-admin-001', targetId: 'seed-listing-007', targetType: AuditTargetType.LISTING, metadata: { reason: 'Thiếu ảnh, pháp lý chưa rõ' }, ipAddress: '103.12.45.67' },
|
||||
];
|
||||
for (const l of logs) {
|
||||
await prisma.adminAuditLog.upsert({ where: { id: l.id }, update: {}, create: l });
|
||||
}
|
||||
console.log(` ✓ ${logs.length} audit logs seeded`);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main seed
|
||||
// =============================================================================
|
||||
|
||||
async function main() {
|
||||
console.log('🌱 Starting seed...\n');
|
||||
console.log('🌱 GoodGo Platform — Comprehensive Seed\n');
|
||||
console.log('━'.repeat(60));
|
||||
|
||||
// Pre-compute password hash
|
||||
console.log('🔑 Hashing default password...');
|
||||
const passwordHash = bcrypt.hashSync(DEFAULT_PASSWORD, BCRYPT_ROUNDS);
|
||||
console.log(` ✓ Password hash computed (bcrypt, ${BCRYPT_ROUNDS} rounds)\n`);
|
||||
|
||||
// Phase 1 — Plans
|
||||
await seedPlans();
|
||||
const users = await seedUsers();
|
||||
await seedProperties(users);
|
||||
console.log('');
|
||||
|
||||
// Phase 2 — Users & Identity
|
||||
await seedUsers(passwordHash);
|
||||
await seedAgents();
|
||||
await seedOAuthAccounts();
|
||||
console.log('');
|
||||
|
||||
// Phase 3 & 4 — Properties, Media, Listings
|
||||
await seedProperties();
|
||||
await seedListings();
|
||||
console.log('');
|
||||
|
||||
// Phase 5 — Subscriptions
|
||||
await seedSubscriptions();
|
||||
console.log('');
|
||||
|
||||
// Phase 6 — Inquiries, Transactions, Leads
|
||||
await seedInquiries();
|
||||
await seedTransactions();
|
||||
await seedLeads();
|
||||
console.log('');
|
||||
|
||||
// Phase 7 — Orders, Escrow, Payments
|
||||
await seedOrdersAndPayments();
|
||||
console.log('');
|
||||
|
||||
// Phase 8 — Reviews, Valuations, Saved Searches
|
||||
await seedReviews();
|
||||
await seedValuations();
|
||||
await seedSavedSearches();
|
||||
console.log('');
|
||||
|
||||
// Phase 9 — Notifications & Audit
|
||||
await seedNotifications();
|
||||
await seedAuditLogs();
|
||||
console.log('');
|
||||
|
||||
// Phase 10 — Market Data
|
||||
await importMarketData();
|
||||
|
||||
console.log('\n✅ Seed completed successfully!');
|
||||
console.log('\n' + '━'.repeat(60));
|
||||
console.log('✅ Comprehensive seed completed successfully!');
|
||||
console.log('━'.repeat(60));
|
||||
console.log('\n📋 Summary:');
|
||||
console.log(' Users: 8 (1 admin, 3 agents, 2 buyers, 2 sellers)');
|
||||
console.log(' Agents: 3 profiles');
|
||||
console.log(' Properties: 10 + 20 media');
|
||||
console.log(' Listings: 10');
|
||||
console.log(' Plans: 4');
|
||||
console.log(' Subscriptions: 6 + 8 usage records');
|
||||
console.log(' Inquiries: 6');
|
||||
console.log(' Transactions: 4');
|
||||
console.log(' Leads: 6');
|
||||
console.log(' Orders: 3');
|
||||
console.log(' Escrows: 2');
|
||||
console.log(' Payments: 6');
|
||||
console.log(' Reviews: 8');
|
||||
console.log(' Valuations: 5');
|
||||
console.log(' Saved Searches: 4');
|
||||
console.log(' Notifications: 10 + 6 prefs');
|
||||
console.log(' Audit Logs: 5');
|
||||
console.log(' Market Index: ~240 records');
|
||||
console.log('\n🔐 Admin Login:');
|
||||
console.log(' Phone: 0876677771');
|
||||
console.log(' Email: hongochai10@icloud.com');
|
||||
console.log(' Password: Velik@2026');
|
||||
console.log(' (All users share the same password)\n');
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -339,4 +554,5 @@ main()
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user