diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml
index 6908ed1..17bdf17 100644
--- a/.github/workflows/load-test.yml
+++ b/.github/workflows/load-test.yml
@@ -13,6 +13,9 @@ on:
- auth
- listings
- search
+ - search-advanced
+ - admin
+ - mcp
- payments
concurrency:
diff --git a/CODEBASE_QUICK_REFERENCE.md b/CODEBASE_QUICK_REFERENCE.md
new file mode 100644
index 0000000..4e097bc
--- /dev/null
+++ b/CODEBASE_QUICK_REFERENCE.md
@@ -0,0 +1,375 @@
+# GoodGo Platform - Quick Reference Guide
+
+## 🗂️ File Structure Quick Links
+
+### Pages (where to place new features)
+- **Inquiry pages**: `apps/web/app/[locale]/(dashboard)/inquiries/`
+- **Lead pages**: `apps/web/app/[locale]/(dashboard)/leads/`
+- **Example pages**: `apps/web/app/[locale]/(dashboard)/listings/` (reference)
+
+### API Layer
+- **Inquiry API**: Create `apps/web/lib/inquiries-api.ts`
+- **Lead API**: Create `apps/web/lib/leads-api.ts`
+- **Base client**: `apps/web/lib/api-client.ts` ← reuse this
+
+### Components
+- **UI base components**: `apps/web/components/ui/` (Button, Card, Badge, Table, Select, Input)
+- **Domain components**: `apps/web/components/inquiries/`, `apps/web/components/leads/`
+- **Example domain component**: `apps/web/components/listings/listing-status-badge.tsx`
+
+### Hooks
+- **Create hooks**: `apps/web/lib/hooks/use-inquiries.ts`, `apps/web/lib/hooks/use-leads.ts`
+- **Example hook**: `apps/web/lib/hooks/use-listings.ts`
+
+### Stores (if needed)
+- **Location**: `apps/web/lib/` (e.g., `inquiry-store.ts`, `lead-store.ts`)
+- **Example**: `apps/web/lib/comparison-store.ts`
+
+### Backend API
+- **Inquiries controller**: `apps/api/src/modules/inquiries/presentation/controllers/inquiries.controller.ts`
+- **Leads controller**: `apps/api/src/modules/leads/presentation/controllers/leads.controller.ts`
+
+---
+
+## 🔌 Backend API Endpoints
+
+### Inquiries
+```
+POST /api/v1/inquiries Create inquiry
+GET /api/v1/inquiries/listing/{id} List by listing (paginated)
+GET /api/v1/inquiries/agent/me List my inquiries (AGENT role)
+PATCH /api/v1/inquiries/{id}/read Mark as read (AGENT role)
+```
+
+### Leads
+```
+POST /api/v1/leads Create lead (AGENT role)
+GET /api/v1/leads List leads (AGENT role, paginated)
+GET /api/v1/leads/stats Get stats (AGENT role)
+PATCH /api/v1/leads/{id}/status Update status (AGENT role)
+DELETE /api/v1/leads/{id} Delete lead (AGENT role)
+```
+
+---
+
+## 🎨 Component Templates
+
+### List Page Template
+```typescript
+'use client';
+import { useQuery } from '@tanstack/react-query';
+import { useTranslations } from 'next-intl';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { Badge } from '@/components/ui/badge';
+import { Select } from '@/components/ui/select';
+
+export default function InquiriesPage() {
+ const t = useTranslations('inquiries');
+ const [filters, setFilters] = useState({ page: 1, status: '' });
+
+ const { data, isLoading } = useQuery({
+ queryKey: ['inquiries', filters],
+ queryFn: () => inquiriesApi.list(filters),
+ });
+
+ return (
+
+
+
{t('title')}
+
+
+ {/* Stats cards */}
+
+
+
+
+ {/* Filters */}
+
+
+
+
+ {/* Table */}
+ {isLoading ? (
+
+ ) : (
+
+
+
+
+
+ {t('name')}
+ {t('status')}
+ {t('actions')}
+
+
+
+ {data?.items.map(item => (
+
+ {item.name}
+ {item.status}
+
+
+
+
+ ))}
+
+
+
+
+ )}
+
+ );
+}
+```
+
+### API Service Template
+```typescript
+// apps/web/lib/inquiries-api.ts
+import { apiClient } from './api-client';
+
+export interface InquiryDto {
+ id: string;
+ listingId: string;
+ userId: string;
+ message: string;
+ isRead: boolean;
+ createdAt: string;
+}
+
+export interface InquiryListResponse {
+ items: InquiryDto[];
+ total: number;
+ page: number;
+ limit: number;
+}
+
+export const inquiriesApi = {
+ list: (params: { page?: number; limit?: number; status?: string }) =>
+ apiClient.get('/inquiries', params),
+
+ getById: (id: string) =>
+ apiClient.get(`/inquiries/${id}`),
+
+ markAsRead: (id: string) =>
+ apiClient.patch(`/inquiries/${id}/read`, {}),
+};
+```
+
+### Hook Template
+```typescript
+// apps/web/lib/hooks/use-inquiries.ts
+import { useQuery } from '@tanstack/react-query';
+import { inquiriesApi } from '@/lib/inquiries-api';
+
+export const inquiriesKeys = {
+ all: ['inquiries'] as const,
+ list: (params: any) => ['inquiries', 'list', params] as const,
+ detail: (id: string) => ['inquiries', 'detail', id] as const,
+};
+
+export function useInquiries(params = {}) {
+ return useQuery({
+ queryKey: inquiriesKeys.list(params),
+ queryFn: () => inquiriesApi.list(params),
+ });
+}
+
+export function useInquiry(id: string) {
+ return useQuery({
+ queryKey: inquiriesKeys.detail(id),
+ queryFn: () => inquiriesApi.getById(id),
+ enabled: !!id,
+ });
+}
+```
+
+### Status Badge Component Template
+```typescript
+// apps/web/components/inquiries/inquiry-status-badge.tsx
+import { Badge } from '@/components/ui/badge';
+
+const INQUIRY_STATUSES = {
+ NEW: { label: 'Mới', variant: 'info' as const },
+ READ: { label: 'Đã xem', variant: 'secondary' as const },
+ REPLIED: { label: 'Đã trả lời', variant: 'success' as const },
+};
+
+export function InquiryStatusBadge({ status }: { status: string }) {
+ const config = INQUIRY_STATUSES[status as keyof typeof INQUIRY_STATUSES] ?? {
+ label: status,
+ variant: 'outline' as const,
+ };
+ return {config.label};
+}
+```
+
+---
+
+## 📝 Translations (i18n)
+
+Add to `apps/web/messages/vi.json` and `apps/web/messages/en.json`:
+
+```json
+{
+ "inquiries": {
+ "title": "Quản lý Liên hệ",
+ "subtitle": "Xem và quản lý các liên hệ từ khách hàng",
+ "allStatus": "Tất cả trạng thái",
+ "new": "Mới",
+ "read": "Đã xem",
+ "replied": "Đã trả lời",
+ "total": "Tổng liên hệ",
+ "thisMonth": "Tháng này",
+ "message": "Tin nhắn",
+ "from": "Từ",
+ "date": "Ngày tạo",
+ "markAsRead": "Đánh dấu đã xem"
+ },
+ "leads": {
+ "title": "Quản lý Khách hàng tiềm năng",
+ "subtitle": "Theo dõi và quản lý khách hàng tiềm năng",
+ "name": "Tên khách hàng",
+ "phone": "Số điện thoại",
+ "email": "Email",
+ "source": "Nguồn",
+ "score": "Điểm số",
+ "status": "Trạng thái",
+ "new": "Mới",
+ "contacted": "Đã liên hệ",
+ "qualified": "Đã xác nhận",
+ "negotiating": "Đang thương lượng",
+ "converted": "Chuyển đổi",
+ "lost": "Mất"
+ }
+}
+```
+
+Usage in components:
+```typescript
+const t = useTranslations('inquiries');
+// or
+const t = useTranslations('leads');
+```
+
+---
+
+## 🎯 Styling Conventions
+
+### Color Classes
+```css
+/* Status indicators */
+.success { @apply text-green-600 bg-green-50 }
+.warning { @apply text-yellow-600 bg-yellow-50 }
+.info { @apply text-blue-600 bg-blue-50 }
+.error { @apply text-red-600 bg-red-50 }
+
+/* Typography */
+.title { @apply text-2xl font-bold }
+.subtitle { @apply text-muted-foreground text-sm }
+.label { @apply text-xs text-muted-foreground uppercase }
+
+/* Layout */
+.card-grid { @apply grid gap-4 sm:grid-cols-2 lg:grid-cols-3 }
+.flex-between { @apply flex items-center justify-between }
+```
+
+### Responsive Breakpoints
+```typescript
+// Mobile first
+className="w-full" // Mobile: full width
+className="sm:w-1/2" // 640px+: 50%
+className="md:w-1/3" // 768px+: 33%
+className="lg:grid-cols-3" // 1024px+: 3 columns
+```
+
+---
+
+## 🔐 Authentication & Authorization
+
+### Protected Pages
+```typescript
+// pages automatically protected by (dashboard) group
+// which has JwtAuthGuard applied via middleware or layout
+
+// For role-specific pages (AGENT only):
+// Use guard directly or check in component
+const { user } = useAuthStore();
+if (!user?.roles.includes('AGENT')) {
+ // redirect or show error
+}
+```
+
+### API Calls with Auth
+```typescript
+// Automatically includes:
+// - httpOnly cookies (JWT)
+// - CSRF token from XSRF-TOKEN cookie
+// - X-CSRF-Token header (POST/PATCH/DELETE)
+
+const { data } = await inquiriesApi.list(); // Auth headers auto-included
+```
+
+---
+
+## 🧪 Testing Patterns
+
+See existing tests in `__tests__` folders for reference:
+- `apps/web/lib/__tests__/auth-store.spec.ts`
+- `apps/web/components/ui/__tests__/`
+
+---
+
+## ✅ Pre-Build Checklist
+
+Before creating Inquiry & Lead pages:
+
+- [ ] Create API service files (`inquiries-api.ts`, `leads-api.ts`)
+- [ ] Create React Query hooks (`use-inquiries.ts`, `use-leads.ts`)
+- [ ] Create status badge components
+- [ ] Add translations to `vi.json` and `en.json`
+- [ ] Create page components under `(dashboard)` group
+- [ ] Test API endpoints with backend
+- [ ] Verify auth guards (JwtAuthGuard, RolesGuard)
+- [ ] Test pagination with query params
+- [ ] Test loading/error states
+- [ ] Test responsive design (mobile/tablet/desktop)
+- [ ] Add JSDoc comments to reusable functions
+- [ ] Test dark mode colors
+
+---
+
+## 📚 Key Files to Reference
+
+```
+REFERENCE PAGES:
+- apps/web/app/[locale]/(dashboard)/listings/page.tsx ← Best example
+- apps/web/app/[locale]/(dashboard)/dashboard/page.tsx ← Stats & cards
+
+REFERENCE COMPONENTS:
+- apps/web/components/listings/listing-status-badge.tsx ← Status badge pattern
+- apps/web/components/search/filter-bar.tsx ← Filter pattern
+- apps/web/components/ui/table.tsx ← Table pattern
+
+REFERENCE HOOKS:
+- apps/web/lib/hooks/use-listings.ts ← React Query pattern
+- apps/web/lib/hooks/use-analytics.ts ← Complex data fetching
+
+REFERENCE STORES:
+- apps/web/lib/auth-store.ts ← Async actions pattern
+- apps/web/lib/comparison-store.ts ← Persistence pattern
+
+REFERENCE API:
+- apps/web/lib/listings-api.ts ← API service pattern
+- apps/web/lib/auth-api.ts ← Auth API pattern
+
+REFERENCE LAYOUT:
+- apps/web/app/[locale]/(dashboard)/layout.tsx ← Dashboard nav
+
+REFERENCE VALIDATION:
+- apps/web/lib/validations/listings.ts ← Zod schema pattern
+```
+
diff --git a/codebase_exploration.md b/codebase_exploration.md
new file mode 100644
index 0000000..dc5242d
--- /dev/null
+++ b/codebase_exploration.md
@@ -0,0 +1,1088 @@
+# GoodGo Platform - Codebase Exploration Report
+
+## 1. Next.js App Router Structure & Patterns
+
+### Directory Organization
+**Location**: `apps/web/app/[locale]/`
+
+The application uses a **group-based routing pattern** with locale support:
+```
+apps/web/app/
+├── [locale]/
+│ ├── layout.tsx (root layout for all routes)
+│ ├── loading.tsx
+│ ├── (public)/
+│ │ ├── layout.tsx
+│ │ ├── loading.tsx
+│ │ ├── page.tsx (home page)
+│ │ ├── listings/[id]/page.tsx
+│ │ ├── agents/[id]/page.tsx
+│ │ ├── search/
+│ │ │ ├── layout.tsx
+│ │ │ ├── loading.tsx
+│ │ │ └── page.tsx
+│ │ ├── compare/page.tsx
+│ │ └── pricing/page.tsx
+│ │
+│ ├── (auth)/
+│ │ ├── layout.tsx
+│ │ ├── loading.tsx
+│ │ ├── login/page.tsx
+│ │ └── register/page.tsx
+│ │
+│ ├── (dashboard)/ ⭐ AUTHENTICATED ROUTES
+│ │ ├── layout.tsx (protected dashboard layout with nav)
+│ │ ├── loading.tsx
+│ │ ├── error.tsx
+│ │ ├── dashboard/
+│ │ │ ├── page.tsx (main dashboard with stats/charts)
+│ │ │ ├── profile/page.tsx
+│ │ │ ├── payments/page.tsx
+│ │ │ ├── subscription/page.tsx
+│ │ │ ├── saved-searches/page.tsx
+│ │ │ ├── valuation/page.tsx
+│ │ │ └── kyc/page.tsx
+│ │ ├── listings/
+│ │ │ ├── page.tsx (listing management with grid/table views)
+│ │ │ ├── new/page.tsx (create listing form)
+│ │ │ └── [id]/edit/page.tsx
+│ │ └── analytics/page.tsx
+│ │
+│ ├── (admin)/
+│ │ ├── layout.tsx
+│ │ ├── admin/
+│ │ │ └── kyc/page.tsx
+│ │ └── admin/users/... (moderation, kyc, etc)
+│ │
+│ ├── auth/
+│ │ └── callback/
+│ │ ├── google/page.tsx
+│ │ └── zalo/page.tsx
+│ └── [other files]
+```
+
+### Key Pattern Files
+
+**Dashboard Layout** (`apps/web/app/[locale]/(dashboard)/layout.tsx`):
+- **Type**: Client component (`'use client'`)
+- **Features**:
+ - Mobile-responsive sidebar + desktop header nav
+ - Dynamic navigation items with icons
+ - User profile display
+ - Theme toggle (light/dark)
+ - Language switcher
+ - Active route detection
+ - Logout functionality
+- **Key Dependencies**:
+ - `next-intl` for translations
+ - `next/navigation` for routing
+ - Custom `useAuthStore` for auth state
+ - `@/components/providers/theme-provider`
+
+**Dashboard Page Example** (`apps/web/app/[locale]/(dashboard)/dashboard/page.tsx`):
+- **Type**: Client component
+- **Features**:
+ - Stats cards with loading states
+ - Dynamic charts (District Bar Chart)
+ - Market data integration
+ - Recent listings grid
+ - Responsive grid layout (2-4 columns)
+- **Data Fetching Pattern**:
+ ```typescript
+ const { data: reportData, isLoading: reportLoading } = useMarketReport(CITY, PERIOD);
+ const { data: heatmapData, isLoading: heatmapLoading } = useHeatmap(CITY, PERIOD);
+ const { data: listings, isLoading: listingsLoading } = useListingsSearch({ page: 1, limit: 6 });
+ ```
+
+### Folder Naming Convention
+- **Route Groups**: Wrapped in parentheses: `(dashboard)`, `(auth)`, `(public)`, `(admin)`
+- **Dynamic Routes**: Square brackets: `[locale]`, `[id]`
+- **Nested Layouts**: Each group can have its own `layout.tsx`
+- **Special Files**:
+ - `page.tsx` - route page
+ - `layout.tsx` - nested layout
+ - `loading.tsx` - loading skeleton/fallback
+ - `error.tsx` - error boundary
+ - `not-found.tsx` - 404 fallback
+
+---
+
+## 2. Existing Components & Component Library
+
+### Location
+`apps/web/components/`
+
+### Component Organization
+```
+components/
+├── ui/ (Base Design System Components)
+│ ├── badge.tsx
+│ ├── button.tsx
+│ ├── card.tsx
+│ ├── dialog.tsx
+│ ├── input.tsx
+│ ├── label.tsx
+│ ├── select.tsx
+│ ├── table.tsx
+│ ├── tabs.tsx
+│ ├── textarea.tsx
+│ ├── language-switcher.tsx
+│ └── __tests__/
+│
+├── listings/ (Domain-Specific Components)
+│ └── listing-status-badge.tsx
+│
+├── search/
+│ ├── filter-bar.tsx
+│ └── search-results.tsx
+│
+├── charts/ (Data Visualization)
+│ └── district-bar-chart.tsx
+│
+├── agents/
+├── auth/
+├── comparison/
+├── map/
+├── providers/
+├── seo/
+└── valuation/
+```
+
+### Key UI Components
+
+#### Badge Component
+```typescript
+// apps/web/components/ui/badge.tsx
+- Variants: default, secondary, destructive, outline, success, warning, info
+- Uses CVA (class-variance-authority) for variant patterns
+- Small pill-shaped badges with Tailwind styling
+```
+
+#### Card Components (Compound Pattern)
+```typescript
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+```
+
+#### Table Components (Compound Pattern)
+```typescript
+export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };
+```
+
+#### Button Component
+- Size variants: default, sm, lg
+- Variant variants: default, outline, ghost, destructive
+- Built with Tailwind + CVA
+
+#### Input/Select/Textarea
+- Standard HTML5 form elements with Tailwind styling
+- Consistent padding and border styling
+
+### Domain-Specific Components
+
+**ListingStatusBadge** (`apps/web/components/listings/listing-status-badge.tsx`):
+```typescript
+interface ListingStatusBadgeProps {
+ status: ListingStatus;
+}
+// Uses LISTING_STATUSES config for mapping status to badge variant + label
+```
+
+**FilterBar** (`apps/web/components/search/filter-bar.tsx`):
+```typescript
+interface SearchFilters {
+ transactionType: string;
+ propertyType: string;
+ city: string;
+ district: string;
+ minPrice: string;
+ maxPrice: string;
+ minArea: string;
+ maxArea: string;
+ bedrooms: string;
+ sort: string;
+}
+
+// Two layouts: horizontal (compact) and sidebar (expanded)
+// Integrates with dropdowns, inputs, and price range presets
+```
+
+### Component Library Patterns
+- **Pattern**: Radix UI-inspired compound components
+- **Styling**: Tailwind CSS + CVA for variants
+- **Accessibility**: Proper ARIA labels, semantic HTML
+- **Theming**: CSS variables for light/dark mode
+- **Reusability**: Props drilling for customization, className composition with `cn()` utility
+
+---
+
+## 3. Zustand Store Patterns
+
+### Location
+`apps/web/lib/`
+
+### Store Pattern Structure
+
+#### Auth Store (`apps/web/lib/auth-store.ts`)
+```typescript
+export interface AuthState {
+ user: UserProfile | null;
+ isAuthenticated: boolean;
+ isLoading: boolean;
+ error: string | null;
+
+ // Actions
+ login: (data: LoginPayload) => Promise;
+ register: (data: RegisterPayload) => Promise;
+ handleOAuthCallback: (accessToken: string, refreshToken: string, expiresIn?: number) => Promise;
+ logout: () => Promise;
+ refreshToken: () => Promise;
+ fetchProfile: () => Promise;
+ initialize: () => Promise;
+ clearError: () => void;
+}
+
+export const useAuthStore = create((set, get) => ({
+ // Initial state
+ user: null,
+ isAuthenticated: false,
+ isLoading: false,
+ error: null,
+
+ // Actions with async handling
+ login: async (data) => {
+ set({ isLoading: true, error: null });
+ try {
+ await authApi.login(data);
+ set({ isAuthenticated: true, isLoading: false });
+ await get().fetchProfile();
+ } catch (e) {
+ const message = e instanceof ApiError ? e.message : 'Đăng nhập thất bại';
+ set({ isLoading: false, error: message });
+ throw e;
+ }
+ },
+ // ... more actions
+}));
+```
+
+#### Comparison Store (`apps/web/lib/comparison-store.ts`)
+```typescript
+export const useComparisonStore = create()(
+ persist(
+ (set, get) => ({
+ selectedIds: [],
+ listings: [],
+ isLoading: false,
+ error: null,
+
+ addToCompare: (id: string) => {
+ const { selectedIds } = get();
+ if (selectedIds.length >= MAX_COMPARE || selectedIds.includes(id)) return false;
+ set({ selectedIds: [...selectedIds, id], error: null });
+ return true;
+ },
+ // ... more actions
+ }),
+ {
+ name: 'goodgo-compare', // localStorage key
+ partialize: (state) => ({ selectedIds: state.selectedIds }), // persist only selectedIds
+ },
+ ),
+);
+```
+
+### Store Patterns Observed
+1. **Type-Safe State**: Generic `` type parameter
+2. **Async Actions**: Direct Promise handling in actions with try/catch
+3. **State Updates**: Using `set()` for immutable updates
+4. **Selectors**: Via `get()` to access current state
+5. **Persistence Middleware**: Optional localStorage persistence with `persist()`
+6. **Partial Persistence**: `partialize` to persist only specific fields
+7. **Error Handling**: Dedicated error state field
+8. **Loading States**: Separate isLoading flag
+
+### Usage Pattern
+```typescript
+// In components
+const { user, isAuthenticated, login, logout } = useAuthStore();
+const { selectedIds, addToCompare, isSelected } = useComparisonStore();
+
+// No selectors - direct property access
+// Store handles re-renders automatically
+```
+
+---
+
+## 4. API Service Layer
+
+### Location
+`apps/web/lib/api-*.ts` files
+
+### API Client Foundation (`apps/web/lib/api-client.ts`)
+
+```typescript
+const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1';
+
+export class ApiError extends Error {
+ constructor(
+ public status: number,
+ message: string,
+ ) { ... }
+}
+
+async function request(endpoint: string, options: RequestOptions = {}): Promise {
+ // Handles:
+ // - CSRF token extraction from cookies (XSRF-TOKEN)
+ // - Credentials (include for cookies)
+ // - Content-Type: application/json
+ // - Error handling with ApiError class
+ // - Status checking (throws on !res.ok)
+}
+
+export const apiClient = {
+ get: (endpoint: string, headers?: HeadersInit) => ...,
+ post: (endpoint: string, body?: unknown, headers?: HeadersInit) => ...,
+ patch: (endpoint: string, body?: unknown, headers?: HeadersInit) => ...,
+ delete: (endpoint: string, headers?: HeadersInit) => ...,
+};
+```
+
+### Listings API (`apps/web/lib/listings-api.ts`)
+
+```typescript
+export const listingsApi = {
+ create: (data: CreateListingPayload) =>
+ apiClient.post<{ listingId: string; propertyId: string; status: string }>(
+ '/listings',
+ data,
+ ),
+
+ getById: (id: string) =>
+ apiClient.get(`/listings/${id}`),
+
+ search: (params: SearchListingsParams = {}) => {
+ // Builds URLSearchParams from params object
+ return apiClient.get>(`/listings${qs ? `?${qs}` : ''}`);
+ },
+
+ updateStatus: (id: string, status: ListingStatus, moderationNotes?: string) =>
+ apiClient.post<{ status: string }>(`/listings/${id}/status`, { status, moderationNotes }),
+
+ uploadMedia: async (listingId: string, file: File, caption?: string) => {
+ // Special handling for FormData (multipart/form-data)
+ // Manual CSRF token handling
+ // Returns { mediaId: string; url: string }
+ },
+};
+```
+
+### API Types Pattern
+```typescript
+export type ListingStatus = 'DRAFT' | 'PENDING_REVIEW' | 'ACTIVE' | ... ;
+export type PropertyType = 'APARTMENT' | 'HOUSE' | ... ;
+
+export interface ListingDetail {
+ id: string;
+ status: ListingStatus;
+ priceVND: string;
+ // ... other fields
+ property: {
+ id: string;
+ propertyType: PropertyType;
+ // ... nested structure
+ };
+}
+
+export interface PaginatedResult {
+ data: T[];
+ total: number;
+ page: number;
+ limit: number;
+ totalPages: number;
+}
+
+export interface SearchListingsParams {
+ status?: ListingStatus;
+ // ... optional filters
+ page?: number;
+ limit?: number;
+}
+```
+
+### Auth Handling
+- **Authentication Method**: Cookie-based (JWT in httpOnly cookies)
+- **CSRF Protection**:
+ - Token extracted from `XSRF-TOKEN` cookie
+ - Sent in `X-CSRF-Token` header for non-safe methods (POST, PATCH, DELETE)
+- **Credentials**: `credentials: 'include'` in fetch calls
+- **Token Refresh**: Automatic refresh in `useAuthStore.fetchProfile()`
+
+### Error Handling Pattern
+```typescript
+try {
+ const data = await listingsApi.search(params);
+} catch (e) {
+ if (e instanceof ApiError) {
+ if (e.status === 401) {
+ // Handle auth error
+ } else if (e.status === 400) {
+ // Handle validation error
+ }
+ }
+ throw e;
+}
+```
+
+---
+
+## 5. Backend Inquiry & Lead API Endpoints
+
+### Inquiries Module
+**Location**: `apps/api/src/modules/inquiries/`
+
+#### Controller Routes (`inquiries.controller.ts`)
+
+```typescript
+@Controller('inquiries') // Base: /api/v1/inquiries
+
+// 1. CREATE INQUIRY
+@Post()
+POST /inquiries
+Payload: CreateInquiryDto
+Response: CreateInquiryResult
+Auth: Required (JwtAuthGuard)
+
+// 2. LIST INQUIRIES BY LISTING
+@Get('listing/:listingId')
+GET /inquiries/listing/{listingId}
+Query Params: page (default 1), limit (default 20, max 100)
+Response: PaginatedResult
+Auth: Required
+
+// 3. LIST MY INQUIRIES (AGENT)
+@Get('agent/me')
+GET /inquiries/agent/me
+Query Params: page, limit
+Response: PaginatedResult
+Auth: Required + AGENT role
+
+// 4. MARK INQUIRY AS READ (AGENT)
+@Patch(':id/read')
+PATCH /inquiries/{id}/read
+Auth: Required + AGENT role
+Response: { success: boolean }
+```
+
+#### DTOs
+
+**CreateInquiryDto**:
+```typescript
+{
+ listingId: string; // Required
+ message: string; // Required, max 2000 chars
+ phone?: string; // Optional
+}
+```
+
+**ListInquiriesDto**:
+```typescript
+{
+ page?: number; // Default 1, Min 1
+ limit?: number; // Default 20, Min 1, Max 100
+}
+```
+
+#### Response DTOs
+
+**InquiryReadDto**:
+```typescript
+{
+ id: string;
+ listingId: string;
+ listingTitle: string;
+ userId: string;
+ userName: string;
+ userPhone: string;
+ message: string;
+ phone: string | null;
+ isRead: boolean;
+ createdAt: string;
+}
+```
+
+**CreateInquiryResult** (from handler):
+```typescript
+// Check handler for exact structure
+// Likely: { inquiryId: string; status: string; ... }
+```
+
+---
+
+### Leads Module
+**Location**: `apps/api/src/modules/leads/`
+
+#### Controller Routes (`leads.controller.ts`)
+
+```typescript
+@Controller('leads') // Base: /api/v1/leads
+@UseGuards(JwtAuthGuard, RolesGuard)
+@Roles('AGENT') // All endpoints require AGENT role
+
+// 1. CREATE LEAD
+@Post()
+POST /leads
+Payload: CreateLeadDto
+Response: CreateLeadResult
+Auth: AGENT role required
+
+// 2. LIST LEADS (AGENT'S LEADS)
+@Get()
+GET /leads
+Query Params: status (filter), page (default 1), limit (default 20)
+Response: PaginatedResult
+Auth: AGENT role required
+
+// 3. GET LEAD STATS
+@Get('stats')
+GET /leads/stats
+Response: LeadStatsData
+Auth: AGENT role required
+
+// 4. UPDATE LEAD STATUS
+@Patch(':id/status')
+PATCH /leads/{id}/status
+Payload: UpdateLeadStatusDto
+Response: { updated: boolean }
+Auth: AGENT role required
+
+// 5. DELETE LEAD
+@Delete(':id')
+DELETE /leads/{id}
+Response: { deleted: boolean }
+Auth: AGENT role required
+```
+
+#### DTOs
+
+**CreateLeadDto**:
+```typescript
+{
+ name: string; // Required (customer name)
+ phone: string; // Required
+ email?: string; // Optional, valid email format
+ source: string; // Required (lead source: 'website', etc)
+ score?: number; // Optional, 0-100
+ notes?: Record; // Optional JSON
+}
+```
+
+**ListLeadsDto**:
+```typescript
+{
+ status?: 'NEW' | 'CONTACTED' | 'QUALIFIED' | 'NEGOTIATING' | 'CONVERTED' | 'LOST';
+ page?: number; // Default 1, Min 1
+ limit?: number; // Default 20, Min 1, Max 100
+}
+```
+
+**UpdateLeadStatusDto**:
+```typescript
+{
+ status: 'NEW' | 'CONTACTED' | 'QUALIFIED' | 'NEGOTIATING' | 'CONVERTED' | 'LOST'; // Required
+}
+```
+
+#### Response DTOs
+
+**LeadReadDto**:
+```typescript
+{
+ id: string;
+ agentId: string;
+ name: string;
+ phone: string;
+ email: string | null;
+ source: string;
+ score: number | null;
+ notes: unknown;
+ status: string;
+ createdAt: string;
+ updatedAt: string;
+}
+```
+
+**LeadStatsData**:
+```typescript
+// Check get-lead-stats query handler for structure
+// Likely: { total: number; byStatus: { [status]: number }; ... }
+```
+
+#### Lead Statuses
+```typescript
+const LEAD_STATUSES = ['NEW', 'CONTACTED', 'QUALIFIED', 'NEGOTIATING', 'CONVERTED', 'LOST'];
+```
+
+---
+
+## 6. Tailwind & Design System
+
+### Configuration
+**Location**: `apps/web/tailwind.config.ts`
+
+```typescript
+const config: Config = {
+ darkMode: ['class'], // Dark mode via CSS class toggle
+ content: [
+ './app/**/*.{ts,tsx}',
+ './components/**/*.{ts,tsx}',
+ './lib/**/*.{ts,tsx}'
+ ],
+ theme: {
+ extend: {
+ fontFamily: {
+ sans: ['var(--font-inter)', 'system-ui', 'sans-serif'],
+ },
+ colors: {
+ // All colors use CSS variables
+ border: 'hsl(var(--border))',
+ input: 'hsl(var(--input))',
+ ring: 'hsl(var(--ring))',
+ background: 'hsl(var(--background))',
+ foreground: 'hsl(var(--foreground))',
+ primary: {
+ DEFAULT: 'hsl(var(--primary))',
+ foreground: 'hsl(var(--primary-foreground))',
+ },
+ secondary: { ... },
+ destructive: { ... },
+ muted: { ... },
+ accent: { ... },
+ card: { ... },
+ },
+ borderRadius: {
+ lg: 'var(--radius)',
+ md: 'calc(var(--radius) - 2px)',
+ sm: 'calc(var(--radius) - 4px)',
+ },
+ },
+ },
+ plugins: [tailwindcssAnimate],
+};
+```
+
+### Theme Colors (CSS Variables)
+**Location**: `apps/web/app/globals.css`
+
+```css
+:root {
+ --background: 0 0% 100%; /* White */
+ --foreground: 222.2 84% 4.9%; /* Dark blue-gray */
+ --card: 0 0% 100%;
+ --card-foreground: 222.2 84% 4.9%;
+ --primary: 142.1 76.2% 36.3%; /* Green */
+ --primary-foreground: 355.7 100% 97.3%;
+ --secondary: 210 40% 96.1%; /* Light gray-blue */
+ --secondary-foreground: 222.2 47.4% 11.2%;
+ --muted: 210 40% 96.1%;
+ --muted-foreground: 215.4 16.3% 46.9%;
+ --accent: 210 40% 96.1%;
+ --accent-foreground: 222.2 47.4% 11.2%;
+ --destructive: 0 84.2% 60.2%; /* Red */
+ --destructive-foreground: 210 40% 98%;
+ --border: 214.3 31.8% 91.4%;
+ --input: 214.3 31.8% 91.4%;
+ --ring: 142.1 76.2% 36.3%; /* Green ring */
+ --radius: 0.5rem; /* 8px border radius */
+}
+
+.dark {
+ --background: 222.2 84% 4.9%; /* Dark navy */
+ --foreground: 210 40% 98%; /* Off white */
+ --primary: 142.1 70.6% 45.3%; /* Lighter green */
+ --primary-foreground: 144.9 80.4% 10%;
+ /* ... other dark mode overrides */
+}
+```
+
+### Design Tokens Pattern
+- **HSL Format**: All colors use `hsl(h s% l%)` for easy manipulation
+- **Semantic Names**: `primary`, `secondary`, `destructive`, `muted`, `accent`
+- **Foreground Pairs**: Each color has a `-foreground` variant for text
+- **Responsive**: Uses `sm:`, `md:`, `lg:` breakpoints
+- **Spacing**: Tailwind default scale (1, 2, 3, 4, 6, 8, 12, 16, 20, 24, 32, etc.)
+- **Typography**: Inter font family with standard weight scale
+
+### Common Tailwind Patterns
+```typescript
+// Cards
+className="rounded-lg border bg-card text-card-foreground shadow-sm"
+
+// Buttons
+className="inline-flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground"
+
+// Grid layouts
+className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
+
+// Flex layouts
+className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
+
+// Status badges with variants
+className="border-transparent bg-green-100 text-green-800" // success
+className="border-transparent bg-yellow-100 text-yellow-800" // warning
+className="border-transparent bg-red-100 text-red-800" // destructive
+```
+
+### Utility Functions
+`apps/web/lib/utils.ts`:
+```typescript
+export function cn(...inputs: ClassValue[]): string {
+ // Merges Tailwind classes, removes conflicts, handles conditions
+ // Used everywhere: className={cn('base-class', condition && 'conditional-class')}
+}
+```
+
+---
+
+## 7. i18n/Locale Patterns
+
+### Configuration
+**Location**: `apps/web/i18n/`
+
+#### Setup Files
+
+**config.ts**:
+```typescript
+export const locales = ['vi', 'en'] as const;
+export type Locale = (typeof locales)[number];
+export const defaultLocale: Locale = 'vi';
+```
+
+**navigation.ts**:
+```typescript
+import { createNavigation } from 'next-intl/navigation';
+import { routing } from './routing';
+
+export const { Link, redirect, usePathname, useRouter } = createNavigation(routing);
+// Provides locale-aware navigation utilities
+```
+
+**routing.ts**:
+```typescript
+import { defineRouting } from 'next-intl/routing';
+
+export const routing = defineRouting({
+ locales: ['vi', 'en'],
+ defaultLocale: 'vi',
+ localePrefix: 'as-needed', // /en/page but not /vi/page (default)
+});
+```
+
+**request.ts** (Server-side i18n setup):
+```typescript
+export default getRequestConfig(async ({ requestLocale }) => {
+ let locale = await requestLocale;
+
+ if (!locale || !routing.locales.includes(locale)) {
+ locale = routing.defaultLocale;
+ }
+
+ return {
+ locale,
+ messages: (await import(`../messages/${locale}.json`)).default,
+ };
+});
+```
+
+### Message Files
+**Location**: `apps/web/messages/`
+
+Files: `vi.json`, `en.json`
+
+Structure:
+```json
+{
+ "metadata": { "title": "...", "description": "..." },
+ "common": { "goodgo": "GoodGo", "loading": "Đang tải...", ... },
+ "nav": { "home": "Trang chủ", "dashboardNav": "Bảng điều khiển", ... },
+ "dashboard": { "title": "Bảng điều khiển", "listings": "Tin đăng", ... },
+ "propertyTypes": { "APARTMENT": "Căn hộ", "HOUSE": "Nhà riêng", ... },
+ "transactionTypes": { "SALE": "Bán", "RENT": "Cho thuê" },
+ "listingStatuses": { "ACTIVE": "Đang bán", "SOLD": "Đã bán", ... }
+}
+```
+
+### Usage in Components
+
+**Client Components**:
+```typescript
+'use client';
+import { useTranslations } from 'next-intl';
+
+export default function MyComponent() {
+ const t = useTranslations('dashboard'); // Namespace
+ return {t('title')}
; // "Bảng điều khiển"
+}
+```
+
+**Navigation Links**:
+```typescript
+import { Link } from '@/i18n/navigation';
+
+// Automatically includes locale prefix
+
+ {t('nav.listings')}
+
+// On /vi -> renders to /vi/listings
+// On /en -> renders to /en/listings
+```
+
+**URL Structure**:
+- Vietnamese (default): `/` → `/listings`, `/dashboard`
+- English: `/en`, `/en/listings`, `/en/dashboard`
+- `localePrefix: 'as-needed'` means default locale (/vi) is omitted from URL
+
+### Translation Namespacing
+Key paths in JSON are hierarchical:
+```typescript
+t('common.logout') // common.logout
+t('dashboard.listings') // dashboard.listings
+t('propertyTypes.APARTMENT') // propertyTypes.APARTMENT
+t('search.filters') // search.filters
+```
+
+### Pluralization & Interpolation
+```typescript
+t('bedroomsCount', { count: n }) // Pluralization support
+t('errorCode', { code: '404' }) // Variable interpolation
+```
+
+---
+
+## 8. Data Fetching & React Query
+
+### Setup
+**Location**: `apps/web/components/providers/query-provider.tsx`
+
+```typescript
+export function QueryProvider({ children }: { children: React.ReactNode }) {
+ const queryClient = getQueryClient();
+ return (
+
+
+ {({ reset }) => (
+
+ {children}
+
+ )}
+
+
+ );
+}
+```
+
+### Query Key Factory Pattern
+**Location**: `apps/web/lib/hooks/use-listings.ts`
+
+```typescript
+export const listingsKeys = {
+ all: ['listings'] as const,
+ search: (params: SearchListingsParams) => ['listings', 'search', params] as const,
+ detail: (id: string) => ['listings', 'detail', id] as const,
+};
+
+// Used in queries:
+useQuery({
+ queryKey: listingsKeys.search(params),
+ queryFn: () => listingsApi.search(params),
+})
+```
+
+### Hook Pattern
+```typescript
+export function useListingsSearch(params: SearchListingsParams = {}) {
+ return useQuery({
+ queryKey: listingsKeys.search(params),
+ queryFn: () => listingsApi.search(params),
+ });
+}
+
+export function useListingDetail(id: string) {
+ return useQuery({
+ queryKey: listingsKeys.detail(id),
+ queryFn: () => listingsApi.getById(id),
+ enabled: !!id, // Don't fetch if id is falsy
+ });
+}
+
+// Usage in components:
+const { data, isLoading, error } = useListingsSearch({ page: 1, limit: 12 });
+const { data: listing } = useListingDetail(id);
+```
+
+### Error Boundary with React Query
+- Provides `QueryErrorFallback` component
+- Shows error message and retry button
+- Automatically resets on successful queries
+
+---
+
+## 9. Key Validation Patterns
+
+### Location
+`apps/web/lib/validations/listings.ts`
+
+```typescript
+// Using Zod for schema validation
+import { z } from 'zod';
+
+// Enum-like objects for UI display + values
+export const TRANSACTION_TYPES = [
+ { value: 'SALE', label: 'Bán' },
+ { value: 'RENT', label: 'Cho thuê' },
+] as const;
+
+export const LISTING_STATUSES = {
+ DRAFT: { label: 'Nháp', variant: 'secondary' as const },
+ ACTIVE: { label: 'Đang bán', variant: 'success' as const },
+ SOLD: { label: 'Đã bán', variant: 'default' as const },
+ // ...
+};
+
+// Zod schemas for form validation
+export const listingBasicSchema = z.object({
+ transactionType: z.enum(['SALE', 'RENT'], {
+ message: 'Vui lòng chọn loại giao dịch',
+ }),
+ propertyType: z.enum(['APARTMENT', 'HOUSE', 'VILLA', ...], {
+ message: 'Vui lòng chọn loại bất động sản',
+ }),
+ title: z.string().min(5, 'Tiêu đề tối thiểu 5 ký tự'),
+ description: z.string().min(10, 'Mô tả tối thiểu 10 ký tự'),
+});
+
+// Composable schemas
+export const createListingSchema = listingBasicSchema
+ .merge(listingLocationSchema)
+ .merge(listingDetailsSchema)
+ .merge(listingPricingSchema);
+
+// Type inference
+export type CreateListingFormData = z.infer;
+```
+
+---
+
+## 10. List Views Pattern (Listings Page Example)
+
+### File
+`apps/web/app/[locale]/(dashboard)/listings/page.tsx`
+
+### Features Implemented
+```typescript
+'use client';
+
+// 1. View Mode Toggle (Grid/Table)
+const [viewMode, setViewMode] = React.useState('grid');
+
+// 2. Filter State Management
+const [filters, setFilters] = React.useState({
+ transactionType: '',
+ propertyType: '',
+ status: '',
+ page: 1,
+});
+
+// 3. Stats Card Section
+- Total listings count
+- Active count (with green color)
+- Pending review count (with yellow color)
+- Total views count
+
+// 4. Filter Controls (Select dropdowns)
+- Transaction Type (Bán/Cho thuê)
+- Property Type (Căn hộ, Nhà riêng, ...)
+- Status (Nháp, Chờ duyệt, Đang bán, ...)
+- View mode toggle buttons
+
+// 5. Data Display
+// Grid View:
+
+ {result.data.map(listing => (
+
+
+
+ {listing.property.title}
+ {listing.property.district}, {listing.property.city}
+ {formatPrice(listing.priceVND)}
+ {/* Stats on hover */}
+ {listing.viewCount} lượt xem
+ {listing.inquiryCount} liên hệ
+
+ ))}
+
+
+// 6. Empty State
+{!result || result.data.length === 0 && (
+
+
Chưa có tin đăng nào
+
+
+
+
+)}
+
+// 7. Loading State
+{loading && (
+
+)}
+```
+
+---
+
+## Summary: Architectural Patterns for Inquiry & Lead Management Pages
+
+Based on this exploration, here are the patterns to follow when building new Inquiry & Lead Management UI pages:
+
+### Page Structure
+1. **Use `(dashboard)` route group** - Place under `apps/web/app/[locale]/(dashboard)/inquiries/` and `/leads/`
+2. **Client component with `'use client'`** - Use React hooks and Zustand
+3. **Include layout/navigation** - Inherited from parent dashboard layout
+4. **Follow naming**: `/inquiries/page.tsx`, `/inquiries/[id]/page.tsx`, `/leads/page.tsx`, etc.
+
+### Data Management
+1. **Create API service files**: `apps/web/lib/inquiries-api.ts`, `apps/web/lib/leads-api.ts`
+2. **Follow apiClient pattern** - Use the generic `apiClient.get/post/patch/delete`
+3. **Use React Query hooks**: `useQuery` with key factory pattern
+4. **Optional Zustand store** - If complex state management needed (like comparison store)
+
+### UI Components
+1. **Use existing `Card`, `Badge`, `Button`, `Select`, `Table` components**
+2. **Build `InquiryStatusBadge`, `LeadStatusBadge`** similar to `ListingStatusBadge`
+3. **Reuse `FilterBar` pattern** - Select dropdowns + inputs for filtering
+4. **Use Table component** for lists - Grid for visual layouts
+5. **Implement pagination** - Use `page` and `limit` params
+
+### Translations
+1. **Add keys to `messages/vi.json` and `messages/en.json`**
+2. **Use `useTranslations('inquiries')` and `useTranslations('leads')`**
+3. **Group translations by feature** - Keep status labels, field names organized
+
+### Styling
+1. **Use Tailwind classes** - `grid`, `gap-4`, `sm:`, `lg:` breakpoints
+2. **Leverage color tokens** - `text-primary`, `bg-card`, `text-muted-foreground`
+3. **Status colors**: Use badge variants `success` (contacted), `warning` (new), `info` (qualified)
+4. **Responsive grid**: `grid gap-4 sm:grid-cols-2 lg:grid-cols-3`
+
+### Loading/Error States
+1. **Loading spinner**: Use rotating border div with `animate-spin`
+2. **Empty states**: Centered flex container with icon + message + CTA button
+3. **Error fallback**: React Query error boundary with retry button
+4. **Conditional rendering**: `{loading && }`, `{!data && }`
+
+### Form Validation
+1. **Create Zod schemas** - Similar to `listingBasicSchema`, `listingLocationSchema`
+2. **Validation messages** - Use Vietnamese language for errors
+3. **API DTOs** - Match backend DTOs exactly
+4. **Type inference** - Use `z.infer`
diff --git a/load-tests/README.md b/load-tests/README.md
index 3caa3aa..281f4bc 100644
--- a/load-tests/README.md
+++ b/load-tests/README.md
@@ -11,6 +11,9 @@ Performance load tests for critical API paths using [K6](https://k6.io/).
| `auth.js` | Login/Register | 100 | 2min |
| `listings.js` | Search + Detail | 500 | 3min |
| `search.js` | Text + Geo search | 200 | 3min |
+| `search-advanced.js` | Combined geo + text + filters, pagination | 300 | 3min |
+| `admin.js` | Moderation queue, dashboard, audit logs | 50 | 2.5min |
+| `mcp.js` | MCP server discovery, property-search, valuation | 120 | 2.5min |
| `payments.js` | Create + List | 50 | 2min |
## SLA Thresholds
@@ -22,6 +25,18 @@ Performance load tests for critical API paths using [K6](https://k6.io/).
| p99 latency | < 1000ms |
| Error rate | < 1% |
+### Per-Suite Custom Thresholds
+
+| Suite | Metric | Threshold |
+|-------|--------|-----------|
+| search-advanced | advanced_search_duration p95 | < 800ms |
+| search-advanced | geo_filter_search_duration p95 | < 800ms |
+| admin | moderation_action_duration p95 | < 800ms |
+| admin | admin_dashboard_duration p95 | < 500ms |
+| mcp | mcp_property_search_duration p95 | < 1500ms |
+| mcp | mcp_valuation_duration p95 | < 1000ms |
+| mcp | mcp_batch_valuation_duration p95 | < 2000ms |
+
## Prerequisites
```bash
@@ -40,6 +55,9 @@ pnpm --filter @goodgo/api run dev
k6 run load-tests/scripts/auth.js
k6 run load-tests/scripts/listings.js
k6 run load-tests/scripts/search.js
+k6 run load-tests/scripts/search-advanced.js
+k6 run load-tests/scripts/admin.js
+k6 run load-tests/scripts/mcp.js
k6 run load-tests/scripts/payments.js
# Run against a custom API URL
@@ -62,11 +80,16 @@ Trigger via `workflow_dispatch` with a suite selector.
```
load-tests/
├── lib/
-│ └── config.js # Shared config, helpers, SLA thresholds
+│ └── config.js # Shared config, helpers, SLA thresholds
├── scripts/
-│ ├── auth.js # Auth flow load tests
-│ ├── listings.js # Listings search + detail
-│ ├── search.js # Full-text + geo search
-│ └── payments.js # Payment creation + listing
+│ ├── auth.js # Auth flow load tests
+│ ├── listings.js # Listings search + detail
+│ ├── search.js # Full-text + geo search (basic)
+│ ├── search-advanced.js # Combined geo + text + filter search
+│ ├── admin.js # Admin moderation, dashboard, audit
+│ ├── mcp.js # MCP server endpoints (property-search, valuation)
+│ └── payments.js # Payment creation + listing
+├── results/
+│ └── BASELINE-REPORT.md # Baseline performance report
└── README.md
```
diff --git a/load-tests/scripts/admin.js b/load-tests/scripts/admin.js
new file mode 100644
index 0000000..00ef340
--- /dev/null
+++ b/load-tests/scripts/admin.js
@@ -0,0 +1,271 @@
+import http from 'k6/http';
+import { check, sleep } from 'k6';
+import { Rate, Trend } from 'k6/metrics';
+import { BASE_URL, SLA_THRESHOLDS, authHeaders } from '../lib/config.js';
+
+/**
+ * Admin Moderation Queue Load Test — Goodgo Platform
+ *
+ * Tests admin endpoints: moderation queue listing, approve/reject,
+ * bulk moderation, user management, dashboard stats, and audit logs.
+ * Requires an admin user account.
+ */
+
+const moderationListDuration = new Trend('moderation_list_duration', true);
+const moderationActionDuration = new Trend('moderation_action_duration', true);
+const adminDashboardDuration = new Trend('admin_dashboard_duration', true);
+const auditLogDuration = new Trend('audit_log_duration', true);
+const userListDuration = new Trend('user_list_duration', true);
+const adminFailRate = new Rate('admin_failures');
+
+export const options = {
+ scenarios: {
+ admin_load: {
+ executor: 'ramping-vus',
+ startVUs: 0,
+ stages: [
+ { duration: '20s', target: 10 }, // admin traffic is lower volume
+ { duration: '1m', target: 30 }, // sustain moderate load
+ { duration: '1m', target: 50 }, // stress peak
+ { duration: '20s', target: 0 }, // ramp down
+ ],
+ gracefulRampDown: '10s',
+ },
+ },
+ thresholds: {
+ ...SLA_THRESHOLDS,
+ moderation_list_duration: ['p(95)<500'],
+ moderation_action_duration: ['p(95)<800'],
+ admin_dashboard_duration: ['p(95)<500'],
+ audit_log_duration: ['p(95)<500'],
+ user_list_duration: ['p(95)<500'],
+ admin_failures: ['rate<0.05'],
+ },
+};
+
+export function setup() {
+ // Register admin user via the standard auth flow.
+ // In a real environment the admin role would be pre-seeded;
+ // for load testing we attempt registration then login.
+ const adminPayload = JSON.stringify({
+ phone: '0900000001',
+ password: 'AdminLoad@1234!',
+ fullName: 'K6 Admin User',
+ email: 'k6-admin@goodgo.test',
+ });
+
+ let res = http.post(`${BASE_URL}/auth/register`, adminPayload, {
+ headers: { 'Content-Type': 'application/json' },
+ tags: { name: 'setup_admin_register' },
+ });
+
+ // If already exists, login
+ if (res.status !== 200 && res.status !== 201) {
+ res = http.post(
+ `${BASE_URL}/auth/login`,
+ JSON.stringify({ phone: '0900000001', password: 'AdminLoad@1234!' }),
+ {
+ headers: { 'Content-Type': 'application/json' },
+ tags: { name: 'setup_admin_login' },
+ },
+ );
+ }
+
+ let accessToken = null;
+ if (res.status === 200 || res.status === 201) {
+ try {
+ accessToken = JSON.parse(res.body).accessToken;
+ } catch (_) { /* ignore */ }
+ }
+
+ // Also create some test listings for moderation queue
+ const listingIds = [];
+ if (accessToken) {
+ for (let i = 0; i < 20; i++) {
+ const listing = {
+ title: `K6 Admin Test Listing ${i}`,
+ description: `Moderation load test listing #${i}`,
+ transactionType: i % 2 === 0 ? 'SALE' : 'RENT',
+ propertyType: ['APARTMENT', 'HOUSE', 'LAND'][i % 3],
+ address: `${200 + i} Đường Kiểm Duyệt`,
+ ward: 'Phường 1',
+ district: 'Quận 1',
+ city: 'TP. Hồ Chí Minh',
+ latitude: 10.7769 + (i * 0.002),
+ longitude: 106.7009 + (i * 0.002),
+ area: 50 + (i * 5),
+ bedrooms: 1 + (i % 4),
+ bathrooms: 1 + (i % 3),
+ floors: 1 + (i % 3),
+ priceVND: 1000000000 + (i * 200000000),
+ direction: 'EAST',
+ };
+
+ const createRes = http.post(
+ `${BASE_URL}/listings`,
+ JSON.stringify(listing),
+ { headers: authHeaders(accessToken), tags: { name: 'setup_create_listing' } },
+ );
+
+ if (createRes.status === 201 || createRes.status === 200) {
+ try {
+ listingIds.push(JSON.parse(createRes.body).id);
+ } catch (_) { /* skip */ }
+ }
+ }
+ }
+
+ return { accessToken, listingIds };
+}
+
+export default function (data) {
+ if (!data.accessToken) {
+ // Without auth, still test unauthenticated error handling
+ const res = http.get(`${BASE_URL}/admin/moderation?page=1&limit=10`, {
+ tags: { name: 'GET /admin/moderation (unauth)' },
+ });
+ check(res, {
+ 'unauth moderation: returns 401': (r) => r.status === 401,
+ });
+ sleep(1);
+ return;
+ }
+
+ const headers = authHeaders(data.accessToken);
+ const iter = __ITER;
+ const scenario = iter % 7;
+
+ if (scenario === 0) {
+ // --- Moderation queue listing ---
+ const page = 1 + (iter % 3);
+ const res = http.get(`${BASE_URL}/admin/moderation?page=${page}&limit=10`, {
+ headers,
+ tags: { name: 'GET /admin/moderation' },
+ });
+
+ moderationListDuration.add(res.timings.duration);
+ const ok = check(res, {
+ 'moderation list: status 200|403': (r) => r.status === 200 || r.status === 403,
+ });
+ if (!ok) adminFailRate.add(1);
+
+ } else if (scenario === 1 && data.listingIds.length > 0) {
+ // --- Approve listing ---
+ const listingId = data.listingIds[iter % data.listingIds.length];
+ const payload = JSON.stringify({
+ listingId,
+ moderationNotes: `K6 load test approval — iteration ${iter}`,
+ });
+
+ const res = http.post(`${BASE_URL}/admin/moderation/approve`, payload, {
+ headers,
+ tags: { name: 'POST /admin/moderation/approve' },
+ });
+
+ moderationActionDuration.add(res.timings.duration);
+ const ok = check(res, {
+ 'approve: status 200|201|403|404|409': (r) =>
+ [200, 201, 403, 404, 409].includes(r.status),
+ });
+ if (!ok) adminFailRate.add(1);
+
+ } else if (scenario === 2 && data.listingIds.length > 0) {
+ // --- Reject listing ---
+ const listingId = data.listingIds[(iter + 1) % data.listingIds.length];
+ const payload = JSON.stringify({
+ listingId,
+ reason: `K6 load test rejection — does not meet criteria (iter ${iter})`,
+ });
+
+ const res = http.post(`${BASE_URL}/admin/moderation/reject`, payload, {
+ headers,
+ tags: { name: 'POST /admin/moderation/reject' },
+ });
+
+ moderationActionDuration.add(res.timings.duration);
+ const ok = check(res, {
+ 'reject: status 200|201|403|404|409': (r) =>
+ [200, 201, 403, 404, 409].includes(r.status),
+ });
+ if (!ok) adminFailRate.add(1);
+
+ } else if (scenario === 3 && data.listingIds.length >= 3) {
+ // --- Bulk moderation ---
+ const startIdx = iter % Math.max(1, data.listingIds.length - 3);
+ const batchIds = data.listingIds.slice(startIdx, startIdx + 3);
+ const payload = JSON.stringify({
+ listingIds: batchIds,
+ action: iter % 2 === 0 ? 'approve' : 'reject',
+ reason: `K6 bulk moderation test — iteration ${iter}`,
+ });
+
+ const res = http.post(`${BASE_URL}/admin/moderation/bulk`, payload, {
+ headers,
+ tags: { name: 'POST /admin/moderation/bulk' },
+ });
+
+ moderationActionDuration.add(res.timings.duration);
+ const ok = check(res, {
+ 'bulk moderate: status 200|201|403|409': (r) =>
+ [200, 201, 403, 409].includes(r.status),
+ });
+ if (!ok) adminFailRate.add(1);
+
+ } else if (scenario === 4) {
+ // --- Admin dashboard ---
+ const res = http.get(`${BASE_URL}/admin/dashboard`, {
+ headers,
+ tags: { name: 'GET /admin/dashboard' },
+ });
+
+ adminDashboardDuration.add(res.timings.duration);
+ const ok = check(res, {
+ 'dashboard: status 200|403': (r) => r.status === 200 || r.status === 403,
+ });
+ if (!ok) adminFailRate.add(1);
+
+ } else if (scenario === 5) {
+ // --- Audit logs with various filters ---
+ const filters = [
+ '',
+ '?action=LISTING_APPROVED',
+ '?action=USER_BANNED',
+ `?startDate=2026-01-01&endDate=2026-12-31`,
+ ];
+ const filter = filters[iter % filters.length];
+
+ const res = http.get(`${BASE_URL}/admin/audit-logs${filter}`, {
+ headers,
+ tags: { name: 'GET /admin/audit-logs' },
+ });
+
+ auditLogDuration.add(res.timings.duration);
+ const ok = check(res, {
+ 'audit logs: status 200|403': (r) => r.status === 200 || r.status === 403,
+ });
+ if (!ok) adminFailRate.add(1);
+
+ } else {
+ // --- User management listing ---
+ const queries = [
+ '?limit=20',
+ '?role=AGENT&limit=10',
+ '?isActive=true&limit=20',
+ '?search=test&limit=10',
+ ];
+ const query = queries[iter % queries.length];
+
+ const res = http.get(`${BASE_URL}/admin/users${query}`, {
+ headers,
+ tags: { name: 'GET /admin/users' },
+ });
+
+ userListDuration.add(res.timings.duration);
+ const ok = check(res, {
+ 'user list: status 200|403': (r) => r.status === 200 || r.status === 403,
+ });
+ if (!ok) adminFailRate.add(1);
+ }
+
+ sleep(Math.random() * 1.5 + 0.5);
+}
diff --git a/load-tests/scripts/mcp.js b/load-tests/scripts/mcp.js
new file mode 100644
index 0000000..c678716
--- /dev/null
+++ b/load-tests/scripts/mcp.js
@@ -0,0 +1,267 @@
+import http from 'k6/http';
+import { check, sleep } from 'k6';
+import { Rate, Trend } from 'k6/metrics';
+import { BASE_URL, SLA_THRESHOLDS, registerTestUser, authHeaders } from '../lib/config.js';
+
+/**
+ * MCP Server Endpoints Load Test — Goodgo Platform
+ *
+ * Tests MCP server discovery, SSE connection establishment, and tool
+ * invocations for property-search and valuation servers. MCP uses an
+ * SSE+message POST pattern — this test exercises both connection setup
+ * and message throughput.
+ */
+
+const mcpServerListDuration = new Trend('mcp_server_list_duration', true);
+const mcpSseConnectDuration = new Trend('mcp_sse_connect_duration', true);
+const mcpPropertySearchDuration = new Trend('mcp_property_search_duration', true);
+const mcpValuationDuration = new Trend('mcp_valuation_duration', true);
+const mcpComparisonDuration = new Trend('mcp_comparison_duration', true);
+const mcpBatchValuationDuration = new Trend('mcp_batch_valuation_duration', true);
+const mcpFailRate = new Rate('mcp_failures');
+
+export const options = {
+ scenarios: {
+ mcp_load: {
+ executor: 'ramping-vus',
+ startVUs: 0,
+ stages: [
+ { duration: '20s', target: 20 }, // warm up
+ { duration: '1m', target: 80 }, // ramp to moderate
+ { duration: '1m', target: 120 }, // stress peak
+ { duration: '20s', target: 0 }, // ramp down
+ ],
+ gracefulRampDown: '10s',
+ },
+ },
+ thresholds: {
+ ...SLA_THRESHOLDS,
+ mcp_server_list_duration: ['p(95)<300'],
+ mcp_sse_connect_duration: ['p(95)<1000'],
+ mcp_property_search_duration: ['p(95)<1500'],
+ mcp_valuation_duration: ['p(95)<1000'],
+ mcp_comparison_duration: ['p(95)<1500'],
+ mcp_batch_valuation_duration: ['p(95)<2000'],
+ mcp_failures: ['rate<0.05'],
+ },
+};
+
+// Property search tool invocations
+const PROPERTY_SEARCH_CALLS = [
+ { query: 'căn hộ quận 1', lat: 10.7769, lng: 106.7009, radiusKm: 5 },
+ { query: 'nhà phố Hà Nội', lat: 21.0285, lng: 105.8542, radiusKm: 3 },
+ { query: 'đất nền Thủ Đức' },
+ { query: 'chung cư giá rẻ', lat: 10.8231, lng: 106.6297, radiusKm: 2 },
+ { query: 'biệt thự Đà Nẵng', lat: 16.0544, lng: 108.2022, radiusKm: 10 },
+ { query: 'apartment for rent', lat: 10.7769, lng: 106.7009, radiusKm: 3 },
+];
+
+// Valuation tool invocations
+const VALUATION_CALLS = [
+ { area: 80, district: 'Quận 1', city: 'TP. Hồ Chí Minh', propertyType: 'APARTMENT', bedrooms: 2, bathrooms: 2, floors: 1 },
+ { area: 120, district: 'Quận 7', city: 'TP. Hồ Chí Minh', propertyType: 'HOUSE', bedrooms: 3, bathrooms: 2, floors: 3, frontage: 5 },
+ { area: 200, district: 'Cầu Giấy', city: 'Hà Nội', propertyType: 'APARTMENT', bedrooms: 3, bathrooms: 2, floors: 1, yearBuilt: 2020 },
+ { area: 500, district: 'Hải Châu', city: 'Đà Nẵng', propertyType: 'LAND', hasLegalPaper: true },
+ { area: 60, district: 'Bình Thạnh', city: 'TP. Hồ Chí Minh', propertyType: 'APARTMENT', bedrooms: 1, bathrooms: 1, floors: 1 },
+ { area: 150, district: 'Ba Đình', city: 'Hà Nội', propertyType: 'HOUSE', bedrooms: 4, bathrooms: 3, floors: 4, roadWidth: 6 },
+];
+
+// Feature extraction text samples (Vietnamese listing descriptions)
+const LISTING_TEXTS = [
+ 'Bán căn hộ cao cấp 80m2, 2PN 2WC, view sông, full nội thất, giá 4.5 tỷ',
+ 'Cho thuê nhà phố mặt tiền đường lớn, 5x20m, 3 lầu, phù hợp kinh doanh',
+ 'Đất nền khu dân cư hiện hữu, sổ đỏ riêng, 100m2 thổ cư, giá 2.8 tỷ',
+ 'Chung cư giá rẻ quận 8, 50m2, 1PN, ban công thoáng mát, 1.2 tỷ',
+ 'Biệt thự Phú Mỹ Hưng 300m2 đất, 4 phòng ngủ, hồ bơi riêng, 25 tỷ',
+];
+
+export function setup() {
+ // Register test users for authenticated MCP access
+ const tokens = [];
+ for (let i = 0; i < 5; i++) {
+ const phone = `0930${String(i).padStart(6, '0')}`;
+ const t = registerTestUser(http, phone);
+ if (t) tokens.push(t.accessToken);
+ }
+ return { tokens };
+}
+
+/**
+ * Build an MCP JSON-RPC message for tool invocation.
+ */
+function mcpToolCall(method, params, id) {
+ return JSON.stringify({
+ jsonrpc: '2.0',
+ id: id || 1,
+ method: method,
+ params: params,
+ });
+}
+
+export default function (data) {
+ const iter = __ITER;
+ const hasAuth = data.tokens.length > 0;
+ const token = hasAuth ? data.tokens[iter % data.tokens.length] : null;
+ const headers = token ? authHeaders(token) : { 'Content-Type': 'application/json' };
+
+ const scenario = iter % 8;
+
+ if (scenario === 0) {
+ // --- List available MCP servers ---
+ const res = http.get(`${BASE_URL}/mcp/servers`, {
+ headers,
+ tags: { name: 'GET /mcp/servers' },
+ });
+
+ mcpServerListDuration.add(res.timings.duration);
+ const ok = check(res, {
+ 'mcp servers: status 200|401|403': (r) =>
+ [200, 401, 403].includes(r.status),
+ 'mcp servers: valid response': (r) => {
+ if (r.status !== 200) return true;
+ try { return Array.isArray(JSON.parse(r.body)); } catch { return false; }
+ },
+ });
+ if (!ok) mcpFailRate.add(1);
+
+ } else if (scenario === 1) {
+ // --- SSE connection to property-search server ---
+ // K6 does not natively support SSE, so we test the initial connection
+ const res = http.get(`${BASE_URL}/mcp/goodgo-property-search/sse`, {
+ headers,
+ tags: { name: 'GET /mcp/property-search/sse' },
+ timeout: '5s',
+ });
+
+ mcpSseConnectDuration.add(res.timings.duration);
+ const ok = check(res, {
+ 'sse connect: status 200|401|403|404': (r) =>
+ [200, 401, 403, 404].includes(r.status),
+ });
+ if (!ok) mcpFailRate.add(1);
+
+ } else if (scenario === 2) {
+ // --- SSE connection to valuation server ---
+ const res = http.get(`${BASE_URL}/mcp/goodgo-valuation/sse`, {
+ headers,
+ tags: { name: 'GET /mcp/valuation/sse' },
+ timeout: '5s',
+ });
+
+ mcpSseConnectDuration.add(res.timings.duration);
+ const ok = check(res, {
+ 'sse connect valuation: status 200|401|403|404': (r) =>
+ [200, 401, 403, 404].includes(r.status),
+ });
+ if (!ok) mcpFailRate.add(1);
+
+ } else if (scenario <= 4) {
+ // --- Property search tool call (25% of traffic) ---
+ const searchParams = PROPERTY_SEARCH_CALLS[iter % PROPERTY_SEARCH_CALLS.length];
+ const message = mcpToolCall('tools/call', {
+ name: 'search_properties',
+ arguments: searchParams,
+ }, iter);
+
+ const sessionId = `k6-session-${__VU}-${iter}`;
+ const res = http.post(
+ `${BASE_URL}/mcp/goodgo-property-search/messages?sessionId=${sessionId}`,
+ message,
+ {
+ headers,
+ tags: { name: 'POST /mcp/property-search/messages' },
+ },
+ );
+
+ mcpPropertySearchDuration.add(res.timings.duration);
+ const ok = check(res, {
+ 'property search: status 200|202|401|403|404': (r) =>
+ [200, 202, 401, 403, 404].includes(r.status),
+ });
+ if (!ok) mcpFailRate.add(1);
+
+ } else if (scenario <= 6) {
+ // --- Valuation tool call (25% of traffic) ---
+ const valuationParams = VALUATION_CALLS[iter % VALUATION_CALLS.length];
+ const message = mcpToolCall('tools/call', {
+ name: 'estimate_property_value',
+ arguments: valuationParams,
+ }, iter);
+
+ const sessionId = `k6-valuation-${__VU}-${iter}`;
+ const res = http.post(
+ `${BASE_URL}/mcp/goodgo-valuation/messages?sessionId=${sessionId}`,
+ message,
+ {
+ headers,
+ tags: { name: 'POST /mcp/valuation/messages' },
+ },
+ );
+
+ mcpValuationDuration.add(res.timings.duration);
+ const ok = check(res, {
+ 'valuation: status 200|202|401|403|404': (r) =>
+ [200, 202, 401, 403, 404].includes(r.status),
+ });
+ if (!ok) mcpFailRate.add(1);
+
+ } else {
+ // --- Feature extraction + batch valuation (12.5% of traffic) ---
+ if (iter % 2 === 0) {
+ // Feature extraction
+ const text = LISTING_TEXTS[iter % LISTING_TEXTS.length];
+ const message = mcpToolCall('tools/call', {
+ name: 'extract_listing_features',
+ arguments: { text },
+ }, iter);
+
+ const sessionId = `k6-extract-${__VU}-${iter}`;
+ const res = http.post(
+ `${BASE_URL}/mcp/goodgo-valuation/messages?sessionId=${sessionId}`,
+ message,
+ {
+ headers,
+ tags: { name: 'POST /mcp/valuation/extract' },
+ },
+ );
+
+ mcpValuationDuration.add(res.timings.duration);
+ const ok = check(res, {
+ 'extract features: status 200|202|401|403|404': (r) =>
+ [200, 202, 401, 403, 404].includes(r.status),
+ });
+ if (!ok) mcpFailRate.add(1);
+ } else {
+ // Batch valuation (2-5 properties)
+ const batchSize = 2 + (iter % 4);
+ const properties = [];
+ for (let i = 0; i < batchSize; i++) {
+ properties.push(VALUATION_CALLS[(iter + i) % VALUATION_CALLS.length]);
+ }
+
+ const message = mcpToolCall('tools/call', {
+ name: 'batch_valuation',
+ arguments: { properties },
+ }, iter);
+
+ const sessionId = `k6-batch-${__VU}-${iter}`;
+ const res = http.post(
+ `${BASE_URL}/mcp/goodgo-valuation/messages?sessionId=${sessionId}`,
+ message,
+ {
+ headers,
+ tags: { name: 'POST /mcp/valuation/batch' },
+ },
+ );
+
+ mcpBatchValuationDuration.add(res.timings.duration);
+ const ok = check(res, {
+ 'batch valuation: status 200|202|401|403|404': (r) =>
+ [200, 202, 401, 403, 404].includes(r.status),
+ });
+ if (!ok) mcpFailRate.add(1);
+ }
+ }
+
+ sleep(Math.random() * 1.5 + 0.5);
+}
diff --git a/load-tests/scripts/search-advanced.js b/load-tests/scripts/search-advanced.js
new file mode 100644
index 0000000..cb577b3
--- /dev/null
+++ b/load-tests/scripts/search-advanced.js
@@ -0,0 +1,161 @@
+import http from 'k6/http';
+import { check, sleep } from 'k6';
+import { Rate, Trend } from 'k6/metrics';
+import { BASE_URL, SLA_THRESHOLDS } from '../lib/config.js';
+
+/**
+ * Advanced Search Load Test — Goodgo Platform
+ *
+ * Tests combined geo + text + filter queries against /search and /search/geo
+ * endpoints. Simulates real user behaviour: multi-filter search, pagination,
+ * sort variations, and geo-bounded text queries.
+ */
+
+const advancedSearchDuration = new Trend('advanced_search_duration', true);
+const geoFilterDuration = new Trend('geo_filter_search_duration', true);
+const paginatedSearchDuration = new Trend('paginated_search_duration', true);
+const searchFailRate = new Rate('advanced_search_failures');
+
+export const options = {
+ scenarios: {
+ advanced_search_load: {
+ executor: 'ramping-vus',
+ startVUs: 0,
+ stages: [
+ { duration: '30s', target: 50 }, // warm up
+ { duration: '1m', target: 200 }, // ramp to peak
+ { duration: '1m', target: 300 }, // stress peak
+ { duration: '30s', target: 0 }, // ramp down
+ ],
+ gracefulRampDown: '10s',
+ },
+ },
+ thresholds: {
+ ...SLA_THRESHOLDS,
+ advanced_search_duration: ['p(95)<800'],
+ geo_filter_search_duration: ['p(95)<800'],
+ paginated_search_duration: ['p(95)<500'],
+ advanced_search_failures: ['rate<0.05'],
+ },
+};
+
+// Vietnamese text queries combined with property filters
+const COMBINED_QUERIES = [
+ { q: 'căn hộ', propertyType: 'APARTMENT', transactionType: 'SALE', priceMin: 1000000000, priceMax: 5000000000 },
+ { q: 'nhà phố', propertyType: 'HOUSE', transactionType: 'SALE', city: 'TP. Hồ Chí Minh' },
+ { q: 'đất nền', propertyType: 'LAND', areaMin: 100, areaMax: 500, district: 'Quận 9' },
+ { q: 'chung cư', transactionType: 'RENT', priceMin: 5000000, priceMax: 20000000, bedrooms: 2 },
+ { q: 'biệt thự', propertyType: 'HOUSE', areaMin: 200, city: 'Hà Nội' },
+ { q: 'apartment', transactionType: 'SALE', priceMin: 2000000000, bedrooms: 3 },
+ { q: 'phòng trọ', transactionType: 'RENT', priceMax: 5000000 },
+ { q: 'villa', propertyType: 'HOUSE', areaMin: 300, priceMin: 10000000000 },
+];
+
+// Geo-bounded searches with various radii and filters
+const GEO_FILTER_QUERIES = [
+ { lat: 10.7769, lng: 106.7009, radiusKm: 5, propertyType: 'APARTMENT', transactionType: 'SALE' },
+ { lat: 10.7769, lng: 106.7009, radiusKm: 2, propertyType: 'HOUSE', priceMin: 3000000000 },
+ { lat: 21.0285, lng: 105.8542, radiusKm: 3, transactionType: 'RENT', bedrooms: 2 },
+ { lat: 10.8231, lng: 106.6297, radiusKm: 1, propertyType: 'LAND', areaMin: 50 },
+ { lat: 16.0544, lng: 108.2022, radiusKm: 10, transactionType: 'SALE' },
+ { lat: 21.0067, lng: 105.8400, radiusKm: 5, propertyType: 'APARTMENT', priceMax: 5000000000 },
+ { lat: 10.8500, lng: 106.7700, radiusKm: 3, propertyType: 'HOUSE', bedrooms: 3 },
+ { lat: 10.7300, lng: 106.6500, radiusKm: 2, transactionType: 'RENT', priceMax: 15000000 },
+];
+
+// Sort options
+const SORT_OPTIONS = ['priceAsc', 'priceDesc', 'newest', 'areaDesc'];
+
+function buildQueryString(params) {
+ const parts = [];
+ for (const [key, value] of Object.entries(params)) {
+ if (value !== undefined && value !== null) {
+ parts.push(`${key}=${encodeURIComponent(value)}`);
+ }
+ }
+ return parts.join('&');
+}
+
+export default function () {
+ const iter = __ITER;
+ const scenario = iter % 5;
+
+ if (scenario <= 1) {
+ // --- Combined text + filter search (40% of traffic) ---
+ const query = COMBINED_QUERIES[iter % COMBINED_QUERIES.length];
+ const sortBy = SORT_OPTIONS[iter % SORT_OPTIONS.length];
+ const qs = buildQueryString({
+ ...query,
+ sortBy,
+ page: 1,
+ perPage: 20,
+ });
+ const url = `${BASE_URL}/search?${qs}`;
+
+ const res = http.get(url, {
+ tags: { name: 'GET /search (advanced)' },
+ });
+
+ advancedSearchDuration.add(res.timings.duration);
+ const ok = check(res, {
+ 'advanced search: status 200|503': (r) => r.status === 200 || r.status === 503,
+ 'advanced search: valid response': (r) => {
+ if (r.status === 503) return true; // Typesense unavailable
+ try { return JSON.parse(r.body).data !== undefined; } catch { return false; }
+ },
+ });
+ if (!ok) searchFailRate.add(1);
+
+ } else if (scenario <= 3) {
+ // --- Geo + filter search (40% of traffic) ---
+ const geoQuery = GEO_FILTER_QUERIES[iter % GEO_FILTER_QUERIES.length];
+ const { lat, lng, radiusKm, ...filters } = geoQuery;
+ const qs = buildQueryString({
+ lat,
+ lng,
+ radiusKm,
+ ...filters,
+ limit: 20,
+ });
+ const url = `${BASE_URL}/search/geo?${qs}`;
+
+ const res = http.get(url, {
+ tags: { name: 'GET /search/geo (filtered)' },
+ });
+
+ geoFilterDuration.add(res.timings.duration);
+ const ok = check(res, {
+ 'geo filter: status 200|503': (r) => r.status === 200 || r.status === 503,
+ 'geo filter: valid response': (r) => {
+ if (r.status === 503) return true;
+ try { return JSON.parse(r.body).data !== undefined; } catch { return false; }
+ },
+ });
+ if (!ok) searchFailRate.add(1);
+
+ } else {
+ // --- Paginated deep search (20% of traffic) ---
+ const query = COMBINED_QUERIES[iter % COMBINED_QUERIES.length];
+ const page = 1 + (iter % 5); // pages 1-5
+ const qs = buildQueryString({
+ q: query.q,
+ propertyType: query.propertyType,
+ page,
+ perPage: 20,
+ sortBy: SORT_OPTIONS[iter % SORT_OPTIONS.length],
+ });
+ const url = `${BASE_URL}/search?${qs}`;
+
+ const res = http.get(url, {
+ tags: { name: 'GET /search (paginated)' },
+ });
+
+ paginatedSearchDuration.add(res.timings.duration);
+ const ok = check(res, {
+ 'paginated search: status 200|503': (r) => r.status === 200 || r.status === 503,
+ });
+ if (!ok) searchFailRate.add(1);
+ }
+
+ sleep(Math.random() * 1 + 0.3);
+}