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