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:
Ho Ngoc Hai
2026-04-13 11:13:04 +07:00
parent db0fe8b9b7
commit a9fa214544
22 changed files with 876 additions and 744 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 lead nào</p>
<Button variant="outline" size="sm" className="mt-3" onClick={() => setCreateOpen(true)}>
Thêm lead đu tiên

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -67,6 +67,7 @@ export interface ListingDetail {
latitude: number | null;
longitude: number | null;
media: PropertyMedia[];
thumbnail?: string | null;
};
seller: {
id: string;

View File

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