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 ### Password Hashing
| 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)
``` ```
GET /subscriptions/plans Algorithm: bcrypt
GET /subscriptions/plans/:tier 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 Valid Formats: 0900000001, 84900000001, +84900000001
PUT /subscriptions/upgrade # Upgrade Normalized: +84900000001
DELETE /subscriptions # Cancel Regex: /^(?:\+84|84|0)(3[2-9]|5[2689]|7[06-9]|8[1-9]|9[0-9])\d{7}$/
GET /subscriptions/quota/:metric # Check quota File: apps/api/src/modules/shared/utils/vietnam-phone.validator.ts
POST /subscriptions/usage # Record usage
GET /subscriptions/billing # View history
``` ```
### Payments (Auth + Webhook) ### Email
``` ```
POST /payments # Create payment → returns paymentUrl Regex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
POST /payments/callback/:provider # Webhook from gateway Normalization: lowercase + trim
GET /payments/:id # Check status Storage: admin@goodgo.vn
GET /payments # List transactions ```
POST /payments/:id/refund # Refund (admin)
### 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 ```typescript
// From subscription-api.ts phone = '0900000001' '+84900000001'
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;
}
``` ```
--- **2. Derive HMAC key**
## How to Use in Frontend
### Get Plans
```typescript ```typescript
import { usePlans } from '@/lib/hooks/use-subscription'; hmacKey = crypto.hkdfSync('sha256', Buffer.from(encryptionKey, 'hex'),
Buffer.alloc(0), Buffer.from('goodgo-field-hash', 'utf8'), 32)
export function MyComponent() {
const { data: plans, isLoading } = usePlans();
return (
<div>
{isLoading ? 'Loading...' : plans?.map(plan => <div>{plan.name}</div>)}
</div>
);
}
``` ```
### Create Payment **3. Compute hashes**
```typescript ```typescript
import { paymentApi } from '@/lib/payment-api'; phoneHash = crypto.createHmac('sha256', hmacKey).update('+84900000001').digest('hex')
import { useMutation } from '@tanstack/react-query'; emailHash = crypto.createHmac('sha256', hmacKey).update('admin@goodgo.vn').digest('hex')
```
const createPaymentMutation = useMutation({ **4. Hash password**
mutationFn: (payload) => paymentApi.createPayment(payload), ```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 ## 🧪 Test Login
### 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
```bash ```bash
# Backend (.env) curl -X POST http://localhost:3000/auth/login \
VNPAY_TMN_CODE=your_tmn_code -H "Content-Type: application/json" \
VNPAY_HASH_SECRET=your_hash_secret -d '{
VNPAY_BASE_URL=https://sandbox.vnpayment.vn/paymentv2/vpcpay.html "phone": "0900000001",
VNPAY_API_URL=https://sandbox.vnpayment.vn/merchant_webapi/api/transaction "password": "AdminPassword123"
}'
```
MOMO_PARTNER_CODE=your_partner_code **Success Response:**
MOMO_ACCESS_KEY=your_access_key ```json
MOMO_SECRET_KEY=your_secret_key {
MOMO_ENDPOINT=https://test-payment.momo.vn/v2/gateway/api "requiresMfa": false,
"tokens": {
ZALOPAY_APP_ID=your_app_id "accessToken": "eyJ...",
ZALOPAY_KEY1=your_key1 "refreshToken": "eyJ...",
ZALOPAY_KEY2=your_key2 "expiresIn": 3600
ZALOPAY_ENDPOINT=https://sandbox.zalopay.com.vn }
}
# Frontend (.env.local)
NEXT_PUBLIC_APP_URL=https://goodgo.vn
``` ```
--- ---
## Testing Credentials ## ⚠️ Common Issues
### VNPay Sandbox | Issue | Fix |
``` |-------|-----|
Terminal: 0 | User can't login | Check: `passwordHash` ≠ null, `isActive` = true |
Account: 0968323286 | "Invalid phone" | Phone must match regex (mobile only) |
Password: 123456 | Hash mismatch | Verify `FIELD_ENCRYPTION_KEY` is consistent |
Card: 9704198526191432198 | MFA issue | Verify `MFA_BACKUP_CODE_SECRET` env var |
OTP: 123456 | PII not encrypted | Verify key is exactly 32 bytes (64 hex chars) |
```
### MoMo Sandbox
```
Phone: 0987654321
Password: 123456
OTP: 123456
```
### ZaloPay Sandbox
```
Phone: 0987654321
OTP: 123456
```
--- ---
## Common Errors ## 📁 Key Files
| Error | Cause | Solution | | File | Purpose |
|-------|-------|----------| |------|---------|
| `ConflictException: User already has active subscription` | User trying to create 2nd subscription | Check existing subscription first | | `hashed-password.vo.ts` | bcrypt hashing |
| `ValidationException: Số tiền phải lớn hơn 0` | Amount is 0 or negative | Ensure amount > 0 | | `vietnam-phone.validator.ts` | Phone validation |
| `NotFoundException: Plan not found` | Plan tier doesn't exist in DB | Check plan is created and isActive=true | | `field-encryption.ts` | AES-256-GCM encryption |
| `Payment gateway failed` | Payment gateway credentials wrong | Verify ENV vars | | `local.strategy.ts` | Login endpoint |
| `Cannot complete payment in status X` | Payment already completed/failed | Check idempotencyKey | | `mfa.service.ts` | TOTP / backup codes |
| `Idempotency check failed` | Same idempotencyKey used twice | Generate unique UUID each time | | `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 - [ ] Password ≥ 8 chars
- [ ] Verify idempotencyKey is unique per request - [ ] Phone matches regex
- [ ] Ensure amountVND matches plan price - [ ] Phone normalized: +84...
- [ ] Check returnUrl is publicly accessible - [ ] Phone hashed: HMAC-SHA256
- [ ] Verify JWT token is valid when calling protected endpoints - [ ] Email lowercased
- [ ] Check payment status with `GET /payments/:id` - [ ] Email hashed: HMAC-SHA256
- [ ] Review payment provider logs/dashboard - [ ] Password hashed: bcrypt (12 rounds)
- [ ] Test with sandbox credentials first - [ ] `isActive: true`
- [ ] Verify callback signature matches gateway requirements - [ ] `passwordHash` ≠ null
- [ ] Check subscription was created after successful payment - [ ] `totpEnabled: false`
- [ ] `totpBackupCodes: []`
--- ---
## Links ## 📚 Full Documentation Files
- Detailed Audit: `PRICING_CHECKOUT_AUDIT.md` 1. **AUTHENTICATION_GUIDE.md** - Complete technical reference
- Summary: `PRICING_AUDIT_SUMMARY.md` 2. **AUTH_IMPLEMENTATION_CHECKLIST.md** - Implementation checklist & troubleshooting
- Pricing Page: `apps/web/app/[locale]/(public)/pricing/page.tsx` 3. **SEED_GENERATION_SCRIPT.ts** - Ready-to-use seed script
- Subscriptions Module: `apps/api/src/modules/subscriptions/` 4. **QUICK_REFERENCE.md** - This file
- Payments Module: `apps/api/src/modules/payments/`
- Schema: `prisma/schema.prisma` (lines 451-514)
---
**Last Updated:** April 12, 2026
**Status:** ✅ Production-Ready

View File

@@ -55,17 +55,17 @@ import { AppController } from './app.controller';
{ {
name: 'default', name: 'default',
ttl: 60_000, 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', name: 'auth',
ttl: 60_000, 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', name: 'payment-callback',
ttl: 60_000, 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
const integrations: any[] = []; const integrations: any[] = [];
if (!isTest) { if (!isTest) {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports try {
const { nodeProfilingIntegration } = require('@sentry/profiling-node') as typeof import('@sentry/profiling-node'); // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
integrations.push(nodeProfilingIntegration()); 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({ Sentry.init({

View File

@@ -45,13 +45,12 @@ describe('GoogleOAuthStrategy', () => {
vi.unstubAllEnvs(); 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', ''); vi.stubEnv('GOOGLE_CLIENT_ID', '');
// Reset module to pick up new env const { GoogleOAuthStrategy } = await import('../strategies/google-oauth.strategy');
expect(async () => { const strategy = new GoogleOAuthStrategy(mockOAuthService as unknown as OAuthService);
const { GoogleOAuthStrategy } = await import('../strategies/google-oauth.strategy'); // Strategy should still be created (graceful fallback with dummy values)
new GoogleOAuthStrategy(mockOAuthService as unknown as OAuthService); expect(strategy).toBeDefined();
}).rejects.toThrow('GOOGLE_CLIENT_ID');
}); });
it('creates strategy with correct config', async () => { it('creates strategy with correct config', async () => {

View File

@@ -30,18 +30,18 @@ describe('ZaloOAuthStrategy', () => {
vi.restoreAllMocks(); 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', ''); vi.stubEnv('ZALO_APP_ID', '');
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() }; 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)) const strategy = new ZaloOAuthStrategy(mockOAuthService as unknown as OAuthService, mockLogger as any);
.toThrow('ZALO_APP_ID'); 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', ''); vi.stubEnv('ZALO_APP_SECRET', '');
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() }; 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)) const strategy = new ZaloOAuthStrategy(mockOAuthService as unknown as OAuthService, mockLogger as any);
.toThrow('ZALO_APP_SECRET'); expect(strategy).toBeDefined();
}); });
describe('getAuthorizationUrl', () => { 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'; const callbackURL = process.env['GOOGLE_CALLBACK_URL'] ?? '/auth/google/callback';
if (!clientID || !clientSecret) { 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({ super({

View File

@@ -49,7 +49,11 @@ export class ZaloOAuthStrategy {
const appSecret = process.env['ZALO_APP_SECRET']; const appSecret = process.env['ZALO_APP_SECRET'];
if (!appId || !appSecret) { 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; this.appId = appId;

View File

@@ -74,6 +74,7 @@ export interface ListingSearchItem {
bedrooms: number | null; bedrooms: number | null;
bathrooms: number | null; bathrooms: number | null;
thumbnail: string | null; thumbnail: string | null;
media: ListingMediaData[];
}; };
seller: { seller: {
id: string; id: string;
@@ -94,5 +95,6 @@ export interface ListingSellerItem {
city: string; city: string;
areaM2: number; areaM2: number;
thumbnail: string | null; thumbnail: string | null;
media: ListingMediaData[];
}; };
} }

View File

@@ -106,7 +106,7 @@ export async function searchListings(
include: { include: {
property: { property: {
include: { include: {
media: { orderBy: { order: 'asc' }, take: 1 }, media: { orderBy: { order: 'asc' }, take: 5 },
}, },
}, },
seller: { select: { id: true, fullName: true } }, seller: { select: { id: true, fullName: true } },
@@ -135,6 +135,13 @@ export async function searchListings(
bedrooms: listing.property.bedrooms, bedrooms: listing.property.bedrooms,
bathrooms: listing.property.bathrooms, bathrooms: listing.property.bathrooms,
thumbnail: listing.property.media[0]?.url ?? null, 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, seller: listing.seller,
})), })),
@@ -182,6 +189,13 @@ export async function findBySellerIdQuery(
city: listing.property.city, city: listing.property.city,
areaM2: listing.property.areaM2, areaM2: listing.property.areaM2,
thumbnail: listing.property.media[0]?.url ?? null, 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, 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" 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"> <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 <Image
src={listing.property.media[0]?.url ?? ''} src={listing.property.media![0]?.url ?? ''}
alt={listing.property.title} alt={listing.property.title}
fill fill
sizes="64px" sizes="64px"

View File

@@ -1,6 +1,24 @@
'use client'; '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 { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useState } from 'react'; import { useState } from 'react';
@@ -11,6 +29,17 @@ import { Link } from '@/i18n/navigation';
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from '@/lib/auth-store';
import { cn } from '@/lib/utils'; 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 }) { export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
@@ -18,20 +47,61 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
const t = useTranslations(); const t = useTranslations();
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const navItems = [ const navGroups: NavGroup[] = [
{ href: '/dashboard' as const, label: t('dashboard.title'), icon: '🏠' }, {
{ href: '/listings' as const, label: t('dashboard.listings'), icon: '📋' }, label: t('dashboard.title'),
{ href: '/listings/new' as const, label: t('dashboard.createListing'), icon: '' }, items: [
{ href: '/inquiries' as const, label: t('dashboard.inquiries'), icon: '💬' }, { href: '/dashboard', label: t('dashboard.title'), icon: Home },
{ href: '/leads' as const, label: t('dashboard.leads'), icon: '🎯' }, { href: '/listings', label: t('dashboard.listings'), icon: List },
{ href: '/analytics' as const, label: t('dashboard.analytics'), icon: '📊' }, { href: '/listings/new', label: t('dashboard.createListing'), icon: Plus },
{ 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: '💎' }, label: 'CRM',
{ href: '/dashboard/payments' as const, label: t('dashboard.payments'), icon: '💳' }, 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 ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
{/* Mobile overlay */} {/* Mobile overlay */}
@@ -43,7 +113,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
/> />
)} )}
{/* Mobile sidebar */} {/* Mobile sidebar — grouped nav */}
<aside <aside
role="navigation" role="navigation"
aria-label={t('nav.dashboardNav')} aria-label={t('nav.dashboardNav')}
@@ -65,22 +135,31 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</button> </button>
</div> </div>
<nav className="flex flex-col gap-1 p-3"> <nav className="flex flex-col gap-4 overflow-y-auto p-3">
{navItems.map((item) => ( {navGroups.map((group) => (
<Link <div key={group.label}>
key={item.href} <p className="mb-1 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground/60">
href={item.href} {group.label}
onClick={() => setSidebarOpen(false)} </p>
className={cn( <div className="flex flex-col gap-0.5">
'flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors', {group.items.map((item) => (
pathname === item.href || (item.href !== '/dashboard' && pathname.startsWith(item.href)) <Link
? 'bg-primary/10 text-primary' key={item.href}
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground', href={item.href}
)} onClick={() => setSidebarOpen(false)}
> className={cn(
<span aria-hidden="true">{item.icon}</span> 'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
{item.label} isActive(item.href)
</Link> ? '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> </nav>
@@ -114,33 +193,57 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
<Menu className="h-5 w-5" /> <Menu className="h-5 w-5" />
</button> </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> <span className="text-lg font-bold text-primary">{t('common.goodgo')}</span>
</Link> </Link>
{/* Desktop nav */} {/* Desktop nav — primary items with labels, secondary icon-only */}
<nav aria-label={t('nav.dashboardNav')} className="hidden items-center space-x-1 md:flex"> <nav aria-label={t('nav.dashboardNav')} className="hidden items-center md:flex">
{navItems.map((item) => ( <div className="flex items-center">
<Link {primaryNav.map((item) => (
key={item.href} <Link
href={item.href} key={item.href}
aria-label={item.label} href={item.href}
className={cn( aria-label={item.label}
'rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground', title={item.label}
pathname === item.href || (item.href !== '/dashboard' && pathname.startsWith(item.href)) className={cn(
? 'bg-accent text-accent-foreground' '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',
: 'text-muted-foreground', isActive(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> <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> </nav>
<div className="ml-auto flex items-center space-x-2"> <div className="ml-auto flex items-center space-x-1">
{user && ( {user && (
<span className="hidden text-sm text-muted-foreground sm:inline"> <span className="hidden text-sm text-muted-foreground lg:inline">
{user.fullName} {user.fullName}
</span> </span>
)} )}
@@ -153,17 +256,14 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
className="h-9 w-9 p-0" className="h-9 w-9 p-0"
> >
{theme === 'light' ? ( {theme === 'light' ? (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"> <Moon className="h-4 w-4" 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>
) : ( ) : (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"> <Sun className="h-4 w-4" 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>
)} )}
</Button> </Button>
<Button variant="ghost" size="sm" className="hidden md:inline-flex" onClick={() => logout()}> <Button variant="ghost" size="sm" className="hidden gap-1.5 md:inline-flex" onClick={() => logout()}>
{t('common.logout')} <LogOut className="h-4 w-4" aria-hidden="true" />
<span className="hidden lg:inline">{t('common.logout')}</span>
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { ClipboardList } from 'lucide-react';
import * as React from 'react'; import * as React from 'react';
import { CreateLeadDialog } from '@/components/leads/create-lead-dialog'; import { CreateLeadDialog } from '@/components/leads/create-lead-dialog';
import { LeadDetailDialog } from '@/components/leads/lead-detail-dialog'; import { LeadDetailDialog } from '@/components/leads/lead-detail-dialog';
@@ -149,7 +150,7 @@ export default function LeadsPage() {
</div> </div>
) : !result || result.data.length === 0 ? ( ) : !result || result.data.length === 0 ? (
<div className="flex min-h-[300px] flex-col items-center justify-center text-muted-foreground"> <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> <p>Chưa lead nào</p>
<Button variant="outline" size="sm" className="mt-3" onClick={() => setCreateOpen(true)}> <Button variant="outline" size="sm" className="mt-3" onClick={() => setCreateOpen(true)}>
Thêm lead đu tiên Thêm lead đu tiên

View File

@@ -184,9 +184,9 @@ export default function ListingsPage() {
<Link key={listing.id} href={`/listings/${listing.id}`}> <Link key={listing.id} href={`/listings/${listing.id}`}>
<Card className="h-full overflow-hidden transition-shadow hover:shadow-md"> <Card className="h-full overflow-hidden transition-shadow hover:shadow-md">
<div className="relative aspect-[4/3] bg-muted"> <div className="relative aspect-[4/3] bg-muted">
{listing.property.media.length > 0 ? ( {(listing.property.media?.length ?? 0) > 0 ? (
<Image <Image
src={listing.property.media[0]?.url ?? ''} src={listing.property.media![0]?.url ?? ''}
alt={listing.property.title} alt={listing.property.title}
fill fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { Building2, CheckCircle2, Home, MapPin, Users, type LucideIcon } from 'lucide-react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import * as React from 'react'; import * as React from 'react';
import { PropertyCard } from '@/components/search/property-card'; import { PropertyCard } from '@/components/search/property-card';
@@ -24,11 +25,11 @@ const DISTRICTS = [
type StatKey = 'listings' | 'users' | 'transactions' | 'provinces'; type StatKey = 'listings' | 'users' | 'transactions' | 'provinces';
const STATS: { key: StatKey; value: string; icon: string }[] = [ const STATS: { key: StatKey; value: string; icon: LucideIcon }[] = [
{ key: 'listings', value: '10,000+', icon: '🏠' }, { key: 'listings', value: '10,000+', icon: Home },
{ key: 'users', value: '50,000+', icon: '👥' }, { key: 'users', value: '50,000+', icon: Users },
{ key: 'transactions', value: '2,000+', icon: '✅' }, { key: 'transactions', value: '2,000+', icon: CheckCircle2 },
{ key: 'provinces', value: '63', icon: '📍' }, { key: 'provinces', value: '63', icon: MapPin },
]; ];
const PROPERTY_TYPE_KEYS = ['APARTMENT', 'HOUSE', 'VILLA', 'LAND', 'OFFICE', 'SHOPHOUSE'] as const; 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"> <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="aspect-[16/9] bg-gradient-to-br from-primary/10 to-primary/5">
<div className="flex h-full items-center justify-center"> <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>
</div> </div>
<CardContent className="p-3"> <CardContent className="p-3">
@@ -231,7 +232,7 @@ export default function LandingPage() {
key={stat.key} key={stat.key}
className="rounded-lg border bg-card p-6 text-center shadow-sm" 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-2 text-3xl font-bold text-primary">{stat.value}</p>
<p className="mt-1 text-sm text-muted-foreground">{t(`stats.${stat.key}`)}</p> <p className="mt-1 text-sm text-muted-foreground">{t(`stats.${stat.key}`)}</p>
</div> </div>

View File

@@ -74,9 +74,9 @@ export function ComparisonTable({ listings, onRemove }: ComparisonTableProps) {
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
{/* Image */} {/* Image */}
<div className="relative aspect-[4/3] w-full max-w-[200px] overflow-hidden rounded-md bg-muted"> <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 <Image
src={listing.property.media[0]?.url ?? ''} src={listing.property.media![0]?.url ?? ''}
alt={listing.property.title} alt={listing.property.title}
fill fill
sizes="200px" sizes="200px"

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { MessageCircle, Phone } from 'lucide-react';
import { InquiryStatusBadge } from '@/components/inquiries/inquiry-row'; import { InquiryStatusBadge } from '@/components/inquiries/inquiry-row';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -80,7 +81,7 @@ export function InquiryDetailDialog({ inquiry, open, onOpenChange }: InquiryDeta
href={`tel:${inquiry.phone ?? inquiry.userPhone}`} 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" 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>
<a <a
href={`https://zalo.me/${(inquiry.phone ?? inquiry.userPhone).replace(/^0/, '84')}`} 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" 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" 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> </a>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { Mail, MessageCircle, Phone } from 'lucide-react';
import * as React from 'react'; import * as React from 'react';
import { LeadStatusBadge } from '@/components/leads/lead-status-badge'; import { LeadStatusBadge } from '@/components/leads/lead-status-badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -151,14 +152,14 @@ export function LeadDetailDialog({ lead, open, onOpenChange }: LeadDetailDialogP
href={`tel:${lead.phone}`} 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" 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>
{lead.email && ( {lead.email && (
<a <a
href={`mailto:${lead.email}`} 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" 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>
)} )}
<a <a
@@ -167,7 +168,7 @@ export function LeadDetailDialog({ lead, open, onOpenChange }: LeadDetailDialogP
rel="noopener noreferrer" 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" 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> </a>
</div> </div>
</div> </div>

View File

@@ -140,9 +140,9 @@ export function ListingMap({ listings, onMarkerClick, selectedListingId, classNa
const container = document.createElement('div'); const container = document.createElement('div');
container.style.fontFamily = 'system-ui,sans-serif'; 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'); 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.alt = listing.property.title;
img.style.cssText = 'width:100%;height:96px;object-fit:cover;border-radius:6px;margin-bottom:8px;'; img.style.cssText = 'width:100%;height:96px;object-fit:cover;border-radius:6px;margin-bottom:8px;';
container.appendChild(img); container.appendChild(img);

View File

@@ -32,9 +32,9 @@ export function PropertyCard({ listing, compact }: PropertyCardProps) {
<Link href={`/listings/${listing.id}`}> <Link href={`/listings/${listing.id}`}>
<Card className="group h-full overflow-hidden transition-shadow hover:shadow-md"> <Card className="group h-full overflow-hidden transition-shadow hover:shadow-md">
<div className={`relative bg-muted ${compact ? 'aspect-[16/10]' : 'aspect-[4/3]'}`}> <div className={`relative bg-muted ${compact ? 'aspect-[16/10]' : 'aspect-[4/3]'}`}>
{listing.property.media.length > 0 ? ( {(listing.property.media?.length ?? 0) > 0 ? (
<Image <Image
src={listing.property.media[0]?.url ?? ''} src={listing.property.media![0]?.url ?? ''}
alt={`Ảnh bất động sản: ${listing.property.title}`} alt={`Ảnh bất động sản: ${listing.property.title}`}
fill fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
@@ -55,10 +55,10 @@ export function PropertyCard({ listing, compact }: PropertyCardProps) {
{propertyTypeLabel} {propertyTypeLabel}
</Badge> </Badge>
</div> </div>
{listing.property.media.length > 1 && ( {(listing.property.media?.length ?? 0) > 1 && (
<div className="absolute bottom-2 right-2"> <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`}> <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 {listing.property.media!.length} nh
</Badge> </Badge>
</div> </div>
)} )}

View File

@@ -1,12 +1,13 @@
'use client'; 'use client';
import { Globe } from 'lucide-react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import type { Locale } from '@/i18n/config'; import type { Locale } from '@/i18n/config';
import { usePathname, useRouter } from '@/i18n/navigation'; import { usePathname, useRouter } from '@/i18n/navigation';
const localeLabels: Record<Locale, string> = { const localeLabels: Record<Locale, string> = {
vi: '🇻🇳 VI', vi: 'VI',
en: '🇬🇧 EN', en: 'EN',
}; };
export function LanguageSwitcher() { 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" 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)}`} 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 aria-hidden="true">{localeLabels[nextLocale]}</span>
<span className="sr-only">{t(nextLocale)}</span> <span className="sr-only">{t(nextLocale)}</span>
</button> </button>

View File

@@ -67,6 +67,7 @@ export interface ListingDetail {
latitude: number | null; latitude: number | null;
longitude: number | null; longitude: number | null;
media: PropertyMedia[]; media: PropertyMedia[];
thumbnail?: string | null;
}; };
seller: { seller: {
id: string; 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 { PrismaPg } from '@prisma/adapter-pg';
import { import {
PrismaClient, PrismaClient,
UserRole, UserRole,
KYCStatus,
PropertyType, PropertyType,
TransactionType, TransactionType,
ListingStatus, ListingStatus,
Direction, Direction,
TransactionStatus,
LeadStatus,
PaymentProvider,
PaymentStatus,
PaymentType,
OrderStatus,
EscrowStatus,
SubscriptionStatus,
PlanTier,
OAuthProvider,
NotificationChannel,
NotificationStatus,
AdminAction,
AuditTargetType,
} from '@prisma/client'; } from '@prisma/client';
import pg from 'pg'; import pg from 'pg';
import { importMarketData } from '../scripts/import-market-data'; // bcrypt is installed in apps/api — resolve from there
import { CITY_COORDINATES } from '../scripts/seed-districts'; import bcrypt from 'bcrypt';
import { seedPlans } from '../scripts/seed-plans'; import { seedPlans } from '../scripts/seed-plans';
import { importMarketData } from '../scripts/import-market-data';
const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] }); const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] });
const adapter = new PrismaPg(pool); const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter }); 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() { const users = [
console.log('Seeding sample 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' },
// Use deterministic IDs so we can upsert by primary key (Prisma 7 requires { 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' },
// unique fields in `where`, and `phone` is no longer unique after the PII { 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' },
// encryption migration added `phoneHash` as the unique index instead). { id: 'seed-buyer-001', phone: '+84900000004', email: 'buyer.le@gmail.com', fullName: 'Lê Minh Cường', role: UserRole.BUYER, kycStatus: KYCStatus.NONE, avatarUrl: null },
const SEED_IDS = { { id: 'seed-buyer-002', phone: '+84900000007', email: 'buyer.hoang@gmail.com', fullName: 'Hoàng Thị Mai', role: UserRole.BUYER, kycStatus: KYCStatus.PENDING, avatarUrl: null },
admin: 'seed-user-admin', { 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' },
agent1: 'seed-user-agent1', { id: 'seed-seller-002', phone: '+84900000008', email: 'seller.vo@gmail.com', fullName: 'Võ Thanh Tùng', role: UserRole.SELLER, kycStatus: KYCStatus.VERIFIED, avatarUrl: null },
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 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++) { console.log(`${users.length} users seeded (all with password: ${DEFAULT_PASSWORD})`);
const p = sampleProperties[i]!; }
const agent = agents[i % agents.length]!
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" ( INSERT INTO "Property" (
"id", "propertyType", "title", "description", "address", "id", "propertyType", "title", "description", "address",
"ward", "district", "city", "location", "ward", "district", "city", "location",
@@ -285,51 +163,388 @@ async function seedProperties(users: Awaited<ReturnType<typeof seedUsers>>) {
"yearBuilt", "legalStatus", "amenities", "nearbyPOIs", "yearBuilt", "legalStatus", "amenities", "nearbyPOIs",
"metroDistanceM", "projectName", "createdAt", "updatedAt" "metroDistanceM", "projectName", "createdAt", "updatedAt"
) VALUES ( ) 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.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.areaM2}, ${p.usableAreaM2 ?? null}, ${p.bedrooms ?? null}, ${p.bathrooms ?? null},
${p.floors ?? null}, ${p.floor ?? null}, ${p.totalFloors ?? null}, ${p.direction ?? null}::"Direction", ${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() ${null}, ${p.projectName ?? null}, NOW(), NOW()
) )
ON CONFLICT ("id") DO NOTHING 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({ await prisma.listing.upsert({
where: { id: `listing-${i + 1}` }, where: { id: l.id },
update: {}, update: {},
create: { create: {
id: `listing-${i + 1}`, ...l, commissionPct: 2.0,
propertyId: `prop-${i + 1}`, viewCount: Math.floor(Math.random() * 500) + 10,
agentId: agent.id, saveCount: Math.floor(Math.random() * 50),
sellerId: users.seller.id, inquiryCount: Math.floor(Math.random() * 20),
transactionType: p.transactionType, publishedAt: isPublished ? oneWeekAgo : null,
status: ListingStatus.ACTIVE, expiresAt: l.status === ListingStatus.ACTIVE ? oneYearLater : null,
priceVND: p.priceVND,
pricePerM2: Number(p.priceVND) / p.areaM2,
publishedAt: new Date(),
}, },
}); });
} }
console.log(`${listings.length} listings seeded`);
console.log(`${sampleProperties.length} properties + 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 // Main seed
// ============================================================================= // =============================================================================
async function main() { 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(); await seedPlans();
const users = await seedUsers(); console.log('');
await seedProperties(users);
// 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(); 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() main()
@@ -339,4 +554,5 @@ main()
}) })
.finally(async () => { .finally(async () => {
await prisma.$disconnect(); await prisma.$disconnect();
await pool.end();
}); });