- Move 8 stray .md (+5 .txt) from ~/Desktop into docs/explorations/from-desktop/ - Reorganize 27 .md/.txt at workspace root: - audit reports -> docs/audits/ - exploration reports -> docs/explorations/ - design system -> docs/design-system/ - Keep only README/CHANGELOG/CONTRIBUTING/CLAUDE at repo root - Refresh docs/README.md as canonical index with links to all groups - Note: pre-existing docs/audits/AUDIT_INDEX.md and AUDIT_SUMMARY.md were overwritten by the newer root-level versions during the move Co-Authored-By: Paperclip <noreply@paperclip.ing>
1056 lines
28 KiB
Markdown
1056 lines
28 KiB
Markdown
# GoodGo Platform — API Endpoints & Data Fields Reference
|
||
**For UI Component Mapping**
|
||
|
||
Generated: April 2026
|
||
|
||
---
|
||
|
||
## Table of Contents
|
||
1. [Analytics Module](#analytics-module)
|
||
- [Market Snapshot](#market-snapshot)
|
||
- [Price Trends](#price-trends)
|
||
- [Heatmap](#heatmap)
|
||
- [District Stats](#district-stats)
|
||
- [Trending Areas](#trending-areas)
|
||
- [Valuations (AVM)](#valuations-avm)
|
||
- [Neighborhood Score](#neighborhood-score)
|
||
- [Nearby POIs](#nearby-pois)
|
||
2. [Listings Module](#listings-module)
|
||
- [Listing Entity](#listing-entity)
|
||
- [Property Entity](#property-entity)
|
||
3. [Agents Module](#agents-module)
|
||
4. [Search Module](#search-module)
|
||
5. [Prisma Schema Key Models](#prisma-schema-key-models)
|
||
|
||
---
|
||
|
||
## Analytics Module
|
||
|
||
### Base Info
|
||
- **Namespace**: `GET/POST /analytics`
|
||
- **Auth**: JWT Bearer Token required (most endpoints)
|
||
- **Rate Limiting**: Varies by endpoint
|
||
- **Caching**: Yes (30min-1hr TTL)
|
||
|
||
---
|
||
|
||
### Market Snapshot
|
||
**Endpoint**: `GET /analytics/market-report`
|
||
|
||
**Query Parameters**:
|
||
```typescript
|
||
{
|
||
city: string; // e.g., "HCMC", "Hanoi"
|
||
period?: string; // time period identifier
|
||
propertyType?: PropertyType; // APARTMENT | VILLA | TOWNHOUSE | LAND | OFFICE | SHOPHOUSE
|
||
}
|
||
```
|
||
|
||
**Response** (`MarketReportDto`):
|
||
```typescript
|
||
{
|
||
// Array of per-district market data
|
||
[
|
||
{
|
||
district: string;
|
||
city: string;
|
||
propertyType: PropertyType;
|
||
period: string;
|
||
medianPrice: string; // formatted price (e.g., "2500000000")
|
||
avgPriceM2: number; // average price per m² (VND)
|
||
totalListings: number; // active listings count
|
||
daysOnMarket: number; // average time to sell (days)
|
||
inventoryLevel: number; // cumulative inventory metric
|
||
absorptionRate: number | null; // listings sold per month (ratio)
|
||
yoyChange: number | null; // year-over-year price change (%)
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Market Snapshot (Quick View)
|
||
**Endpoint**: `GET /analytics/market-snapshot`
|
||
|
||
**Query Parameters**:
|
||
```typescript
|
||
{
|
||
city: string; // e.g., "HCMC"
|
||
propertyType?: PropertyType; // optional filter
|
||
}
|
||
```
|
||
|
||
**Response** (`MarketSnapshotDto`):
|
||
```typescript
|
||
{
|
||
city: string;
|
||
propertyType?: PropertyType;
|
||
activeCount: number; // total active listings
|
||
avgPrice: number; // average listing price (VND)
|
||
medianPrice: number; // median listing price (VND)
|
||
priceChangePct: {
|
||
d1: number; // % change in last 1 day
|
||
d7: number; // % change in last 7 days
|
||
d30: number; // % change in last 30 days
|
||
};
|
||
avgPricePerM2: number; // average price per m² (VND)
|
||
daysOnMarket: number; // average days on market
|
||
newListings24h: number; // new listings in last 24 hours
|
||
cachedAt: string | null; // ISO timestamp of cache
|
||
nextRefreshAt: string | null; // ISO timestamp of next refresh
|
||
}
|
||
```
|
||
|
||
**Cache**: 30 minutes
|
||
|
||
---
|
||
|
||
### Price Trends
|
||
**Endpoint**: `GET /analytics/price-trend`
|
||
|
||
**Query Parameters**:
|
||
```typescript
|
||
{
|
||
district: string; // e.g., "Quận 1"
|
||
city: string; // e.g., "Hồ Chí Minh"
|
||
propertyType: PropertyType; // required
|
||
periods?: string[]; // comma-separated time periods
|
||
}
|
||
```
|
||
|
||
**Response** (`PriceTrendDto`):
|
||
```typescript
|
||
{
|
||
district: string;
|
||
city: string;
|
||
propertyType: string;
|
||
trend: [
|
||
{
|
||
period: string; // e.g., "2026-Q1"
|
||
medianPrice: string; // formatted price (VND)
|
||
avgPriceM2: number;
|
||
totalListings: number;
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
**Use Case**: Line chart for price evolution over time
|
||
**Cache**: 1 hour
|
||
|
||
---
|
||
|
||
### Heatmap
|
||
**Endpoint**: `GET /analytics/heatmap`
|
||
|
||
**Query Parameters**:
|
||
```typescript
|
||
{
|
||
city: string; // e.g., "HCMC"
|
||
period: string; // e.g., "2026-04"
|
||
}
|
||
```
|
||
|
||
**Response** (`HeatmapDto`):
|
||
```typescript
|
||
{
|
||
city: string;
|
||
period: string;
|
||
dataPoints: [
|
||
{
|
||
district: string;
|
||
city: string;
|
||
avgPriceM2: number; // price intensity for heatmap color
|
||
totalListings: number;
|
||
medianPrice: string; // formatted price (VND)
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
**UI Mapping**: Use `avgPriceM2` for color intensity, district names for overlay
|
||
**Cache**: 1 hour
|
||
|
||
---
|
||
|
||
### District Stats
|
||
**Endpoint**: `GET /analytics/district-stats`
|
||
|
||
**Query Parameters**:
|
||
```typescript
|
||
{
|
||
city: string; // e.g., "HCMC"
|
||
period: string; // e.g., "2026-04"
|
||
}
|
||
```
|
||
|
||
**Response** (`DistrictStatsDto`):
|
||
```typescript
|
||
{
|
||
city: string;
|
||
period: string;
|
||
districts: [
|
||
{
|
||
district: string;
|
||
city: string;
|
||
propertyType: PropertyType;
|
||
medianPrice: string; // formatted price (VND)
|
||
avgPriceM2: number;
|
||
totalListings: number;
|
||
daysOnMarket: number;
|
||
inventoryLevel: number; // supply metric
|
||
absorptionRate: number | null; // velocity metric
|
||
yoyChange: number | null; // year-over-year % change
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
**UI Mapping**: Table or grid showing stats per district
|
||
**Cache**: 6 hours
|
||
|
||
---
|
||
|
||
### Trending Areas
|
||
**Endpoint**: `GET /analytics/trending-areas`
|
||
|
||
**Query Parameters**:
|
||
```typescript
|
||
{
|
||
period?: number; // days (default: 30)
|
||
limit?: number; // max areas to return (default: 10)
|
||
level?: string; // "district" | "ward" (default: "district")
|
||
}
|
||
```
|
||
|
||
**Response** (`TrendingAreasDto`):
|
||
```typescript
|
||
{
|
||
period: number; // query period in days
|
||
level: string; // "district" | "ward"
|
||
limit: number;
|
||
areas: [
|
||
{
|
||
districtId: string; // district identifier
|
||
name: string; // display name (e.g., "Quận 1")
|
||
listings: number; // new listings in period
|
||
inquiries: number; // buyer inquiries
|
||
views: number; // total view count
|
||
priceChangePct: number | null; // YoY price change (%)
|
||
scoreRank: number; // rank (1 = hottest)
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
**Scoring Formula**: `score = inquiries × 0.6 + views × 0.3 + listings × 0.1`
|
||
**Cache**: 30 minutes
|
||
|
||
---
|
||
|
||
### Valuations (AVM)
|
||
|
||
#### Get Valuation (by Property ID or Coordinates)
|
||
**Endpoint**: `GET /analytics/valuation`
|
||
|
||
**Query Parameters**:
|
||
```typescript
|
||
{
|
||
propertyId?: string; // if querying existing property
|
||
latitude?: number; // if querying coordinates
|
||
longitude?: number;
|
||
areaM2?: number; // area required if no propertyId
|
||
propertyType?: PropertyType;
|
||
}
|
||
```
|
||
|
||
**Response** (`ValuationDto`):
|
||
```typescript
|
||
{
|
||
estimatedPrice: string; // formatted price (e.g., "2500000000" VND)
|
||
confidence: number; // 0.0-1.0 confidence score
|
||
pricePerM2: number; // estimated price per m²
|
||
comparables: [
|
||
{
|
||
propertyId: string;
|
||
address: string;
|
||
district: string;
|
||
priceVND: string; // comparable listing price
|
||
pricePerM2: number;
|
||
areaM2: number;
|
||
propertyType: PropertyType;
|
||
distanceMeters: number; // distance from query point
|
||
soldAt: string; // ISO timestamp
|
||
}
|
||
];
|
||
modelVersion: string; // "v1" | "v2" | "ensemble"
|
||
confidenceExplanation?: string; // explanation of confidence
|
||
}
|
||
```
|
||
|
||
**Cache**: 24 hours
|
||
**Rate Limit**: 10 req/min per user (on POST endpoint)
|
||
|
||
---
|
||
|
||
#### Predict Valuation (Manual Input)
|
||
**Endpoint**: `POST /analytics/valuation`
|
||
|
||
**Request Body** (`PredictValuationDto`):
|
||
```typescript
|
||
{
|
||
// ── Core Fields (v1 & v2) ──
|
||
propertyType: PropertyType; // required
|
||
area: number; // m² (required, min: 1)
|
||
district: string; // required (e.g., "Quận 1")
|
||
city: string; // required (e.g., "Hồ Chí Minh")
|
||
|
||
// ── Optional Details ──
|
||
bedrooms?: number; // 0-20
|
||
bathrooms?: number; // 0-20
|
||
floors?: number; // 0-200
|
||
frontage?: number; // meters (m)
|
||
roadWidth?: number; // meters (m)
|
||
yearBuilt?: number; // 1900-2100
|
||
hasLegalPaper?: boolean;
|
||
latitude?: number; // if available
|
||
longitude?: number;
|
||
projectId?: string;
|
||
imageUrl?: string;
|
||
description?: string; // max 2000 chars
|
||
deepAnalysis?: boolean; // triggers Claude analysis
|
||
|
||
// ── AVM v2 Features ──
|
||
useV2?: boolean; // enable enhanced model
|
||
distanceToHospitalKm?: number;
|
||
distanceToParkKm?: number;
|
||
distanceToMallKm?: number;
|
||
floodZoneRisk?: 'NONE' | 'LOW' | 'MEDIUM' | 'HIGH';
|
||
hasElevator?: boolean;
|
||
hasParking?: boolean;
|
||
hasPool?: boolean;
|
||
}
|
||
```
|
||
|
||
**Response** (`ValuationDto` - same as above)
|
||
|
||
**Rate Limit**: 10 req/min per user
|
||
|
||
---
|
||
|
||
#### Batch Valuation
|
||
**Endpoint**: `POST /analytics/valuation/batch`
|
||
|
||
**Request Body**:
|
||
```typescript
|
||
{
|
||
propertyIds: string[]; // 1-50 property IDs
|
||
}
|
||
```
|
||
|
||
**Response**:
|
||
```typescript
|
||
{
|
||
results: [
|
||
{
|
||
propertyId: string;
|
||
valuation: ValuationDto | null;
|
||
error?: string; // error message if failed
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
**Rate Limit**: 10 req/min per user
|
||
|
||
---
|
||
|
||
#### Valuation History (Chart Data)
|
||
**Endpoint**: `GET /analytics/valuation/history/:propertyId`
|
||
|
||
**Query Parameters**:
|
||
```typescript
|
||
{
|
||
limit?: number; // default: 50, max: 365
|
||
}
|
||
```
|
||
|
||
**Response**:
|
||
```typescript
|
||
{
|
||
propertyId: string;
|
||
history: [
|
||
{
|
||
estimatedPrice: string;
|
||
confidence: number;
|
||
pricePerM2: number;
|
||
modelVersion: string;
|
||
valuedAt: string; // ISO timestamp
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
**UI Mapping**: Time-series chart of valuation estimates
|
||
**Cache**: 24 hours
|
||
|
||
---
|
||
|
||
#### Compare Valuations
|
||
**Endpoint**: `POST /analytics/valuation/compare`
|
||
|
||
**Request Body**:
|
||
```typescript
|
||
{
|
||
propertyIds: string[]; // 2-5 property IDs
|
||
}
|
||
```
|
||
|
||
**Response**:
|
||
```typescript
|
||
{
|
||
comparisons: [
|
||
{
|
||
propertyId: string;
|
||
address: string;
|
||
district: string;
|
||
areaM2: number;
|
||
propertyType: PropertyType;
|
||
valuation: ValuationDto | null;
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
**UI Mapping**: Side-by-side comparison table
|
||
**Rate Limit**: 10 req/min per user
|
||
|
||
---
|
||
|
||
### Neighborhood Score
|
||
**Endpoint**: `GET /analytics/neighborhoods/:district/score`
|
||
|
||
**Query Parameters**:
|
||
```typescript
|
||
{
|
||
city?: string; // default: "Hồ Chí Minh"
|
||
}
|
||
```
|
||
|
||
**Response** (`NeighborhoodScoreResult`):
|
||
```typescript
|
||
{
|
||
district: string;
|
||
city: string;
|
||
educationScore: number; // 0-100
|
||
healthcareScore: number; // 0-100
|
||
transportScore: number; // 0-100
|
||
shoppingScore: number; // 0-100
|
||
greeneryScore: number; // 0-100
|
||
safetyScore: number; // 0-100
|
||
totalScore: number; // weighted average 0-100
|
||
poiCounts: {
|
||
schools: number;
|
||
hospitals: number;
|
||
transit: number;
|
||
shopping: number;
|
||
parks: number;
|
||
restaurants: number;
|
||
[key: string]: number;
|
||
};
|
||
calculatedAt: Date; // ISO timestamp
|
||
}
|
||
```
|
||
|
||
**UI Mapping**: Score gauge + POI count breakdown
|
||
**Cache**: 24 hours
|
||
**Public**: Yes (no auth required)
|
||
|
||
---
|
||
|
||
### Nearby POIs
|
||
**Endpoint**: `GET /analytics/pois/nearby` (Public)
|
||
|
||
**Query Parameters**:
|
||
```typescript
|
||
{
|
||
lat: number; // latitude (required)
|
||
lng: number; // longitude (required)
|
||
radius?: number; // meters (default: 2000)
|
||
limit?: number; // max results (default: 30)
|
||
}
|
||
```
|
||
|
||
**Response** (`NearbyPOIsResultDto`):
|
||
```typescript
|
||
{
|
||
pois: [
|
||
{
|
||
id: string;
|
||
name: string;
|
||
type: POIType; // SCHOOL | HOSPITAL | METRO_STATION | MALL | PARK | RESTAURANT | etc.
|
||
category: POICategory; // 'school' | 'hospital' | 'transit' | 'shopping' | 'restaurant' | 'park'
|
||
lat: number;
|
||
lng: number;
|
||
distance: number; // meters from query point
|
||
address: string | null;
|
||
}
|
||
];
|
||
center: {
|
||
lat: number;
|
||
lng: number;
|
||
};
|
||
}
|
||
```
|
||
|
||
**UI Mapping**: Map markers grouped by category + list view
|
||
**Cache**: 1 hour
|
||
**Public**: Yes
|
||
|
||
---
|
||
|
||
## Listings Module
|
||
|
||
### Listing Entity Fields
|
||
|
||
**Entity**: `ListingEntity`
|
||
|
||
```typescript
|
||
{
|
||
id: string; // unique listing ID
|
||
propertyId: string; // reference to Property
|
||
agentId: string | null; // assigned agent
|
||
sellerId: string; // seller/owner User ID
|
||
transactionType: TransactionType; // SALE | RENT
|
||
status: ListingStatus; // DRAFT | PENDING_REVIEW | ACTIVE | RESERVED | SOLD | RENTED | EXPIRED | REJECTED
|
||
|
||
// Pricing
|
||
price: Price; // Price VO (amount in VND)
|
||
pricePerM2: number | null; // calculated price per m²
|
||
rentPriceMonthly: bigint | null; // for rental listings
|
||
commissionPct: number | null; // agent commission (default: 2.0%)
|
||
|
||
// AI & Moderation
|
||
aiPriceEstimate: bigint | null; // AVM-generated price estimate (VND)
|
||
aiConfidence: number | null; // AVM confidence (0.0-1.0)
|
||
moderationScore: number | null; // quality score (0-100)
|
||
moderationNotes: string | null; // moderation feedback
|
||
|
||
// Engagement Metrics
|
||
viewCount: number; // total views
|
||
saveCount: number; // times saved by users
|
||
inquiryCount: number; // buyer inquiries
|
||
|
||
// Dates
|
||
featuredUntil: Date | null; // promoted listing expiry
|
||
expiresAt: Date | null; // listing expiration date
|
||
publishedAt: Date | null; // date activated (ACTIVE status)
|
||
createdAt: Date;
|
||
updatedAt: Date;
|
||
}
|
||
```
|
||
|
||
**UI Field Mapping**:
|
||
- **Header**: `price` (formatted), `transactionType`, `status`
|
||
- **Metadata**: `pricePerM2`, `aiPriceEstimate` (with confidence badge)
|
||
- **Engagement**: `viewCount`, `inquiryCount`, `saveCount`
|
||
- **Status Badge**: `status`, `moderationScore` if available
|
||
|
||
---
|
||
|
||
### Property Entity Fields
|
||
|
||
**Entity**: `PropertyEntity`
|
||
|
||
```typescript
|
||
{
|
||
id: string; // unique property ID
|
||
|
||
// Core Info
|
||
propertyType: PropertyType; // APARTMENT | VILLA | TOWNHOUSE | LAND | OFFICE | SHOPHOUSE
|
||
title: string; // property title
|
||
description: string; // detailed description
|
||
|
||
// Location
|
||
address: Address; // VO: { street, ward, district, city }
|
||
location: GeoPoint; // VO: { lat, lng } (PostGIS geometry)
|
||
ward: string; // administrative ward
|
||
district: string;
|
||
city: string;
|
||
|
||
// Physical Attributes
|
||
areaM2: number; // total area (m²)
|
||
usableAreaM2: number | null; // usable area (m²)
|
||
bedrooms: number | null; // -1 means "studio"
|
||
bathrooms: number | null;
|
||
floors: number | null; // building height in floors
|
||
floor: number | null; // unit floor level
|
||
totalFloors: number | null; // total building floors
|
||
direction: Direction | null; // NORTH | SOUTH | EAST | WEST | NORTHEAST | etc.
|
||
yearBuilt: number | null;
|
||
legalStatus: string | null; // e.g., "Sở hữu lâu dài"
|
||
|
||
// Infrastructure & Amenities
|
||
amenities: unknown; // JSON array of amenity names
|
||
nearbyPOIs: unknown; // JSON array of nearby POI references
|
||
metroDistanceM: number | null; // distance to nearest metro (meters)
|
||
projectName: string | null; // if part of a development project
|
||
projectDevelopmentId: string | null;
|
||
|
||
// Enhanced Fields (Phase B)
|
||
furnishing: Furnishing | null; // FULLY_FURNISHED | BASIC_FURNISHED | UNFURNISHED
|
||
propertyCondition: PropertyCondition | null; // NEW | LIKE_NEW | RENOVATED | USED
|
||
balconyDirection: Direction | null;
|
||
maintenanceFeeVND: bigint | null; // monthly/annual fee
|
||
parkingSlots: number | null;
|
||
viewType: string[]; // e.g., ["street", "park", "water"]
|
||
petFriendly: boolean | null;
|
||
suitableFor: string[]; // e.g., ["young_couples", "families", "investors"]
|
||
whyThisLocation: string | null; // narrative about location
|
||
|
||
createdAt: Date;
|
||
updatedAt: Date;
|
||
}
|
||
```
|
||
|
||
**UI Field Mapping**:
|
||
- **Hero Section**: `propertyType`, `title`, `bedrooms/bathrooms`, `areaM2`
|
||
- **Location Badge**: `district`, `ward`, city
|
||
- **Details Grid**: `yearBuilt`, `floors`, `direction`, `furnishing`, `propertyCondition`
|
||
- **Amenities**: `amenities[]`, `nearbyPOIs[]`
|
||
- **Lifestyle**: `suitableFor[]`, `whyThisLocation`, `petFriendly`
|
||
|
||
---
|
||
|
||
## Agents Module
|
||
|
||
### Agent Entity Fields
|
||
|
||
**Entity**: `AgentEntity`
|
||
|
||
```typescript
|
||
{
|
||
id: string; // agent profile ID (different from userId)
|
||
userId: string; // reference to User (role=AGENT)
|
||
|
||
// License & Credentials
|
||
licenseNumber: string | null;
|
||
agency: string | null; // brokerage/agency name
|
||
isVerified: boolean; // license verified by admin
|
||
|
||
// Performance
|
||
qualityScore: QualityScore; // VO: { value: number, breakdown: {...} }
|
||
totalDeals: number; // lifetime transactions
|
||
responseTimeAvg: number | null; // seconds
|
||
|
||
// Profile
|
||
bio: string | null; // agent biography/pitch
|
||
serviceAreas: string[]; // list of district IDs (e.g., ["quan-1", "quan-7"])
|
||
|
||
createdAt: Date;
|
||
updatedAt: Date;
|
||
}
|
||
```
|
||
|
||
**QualityScore VO**:
|
||
```typescript
|
||
{
|
||
value: number; // 0.0-100.0 composite score
|
||
// Potentially includes breakdown by metric
|
||
}
|
||
```
|
||
|
||
**UI Field Mapping**:
|
||
- **Agent Card**: `name` (from User), `agency`, `qualityScore.value` (star rating)
|
||
- **Badges**: `isVerified`, `licenseNumber` (if available)
|
||
- **Stats**: `totalDeals`, `responseTimeAvg` (e.g., "avg 2h response")
|
||
- **Service Areas**: `serviceAreas[]` (comma-separated or chip list)
|
||
- **Bio**: `bio` (short preview)
|
||
|
||
---
|
||
|
||
## Search Module
|
||
|
||
### Search Result Format
|
||
|
||
**Endpoint**: Search is handled by Meilisearch (typesense-compatible)
|
||
|
||
**Search Result Item** (`ListingDocument`):
|
||
```typescript
|
||
{
|
||
id: string; // document ID in search index
|
||
listingId: string; // reference to Listing
|
||
propertyId: string; // reference to Property
|
||
|
||
// Display
|
||
title: string;
|
||
description: string;
|
||
|
||
// Classification
|
||
propertyType: string; // "APARTMENT", "VILLA", etc.
|
||
transactionType: string; // "SALE", "RENT"
|
||
|
||
// Price
|
||
priceVND: number; // for sorting/filtering
|
||
pricePerM2: number | null;
|
||
|
||
// Property Details
|
||
areaM2: number;
|
||
bedrooms: number | null;
|
||
bathrooms: number | null;
|
||
floors: number | null;
|
||
direction: string | null;
|
||
|
||
// Location
|
||
address: string; // full address string
|
||
ward: string;
|
||
district: string;
|
||
city: string;
|
||
location: [number, number]; // [lat, lng]
|
||
|
||
// Relations
|
||
agentId: string | null;
|
||
sellerId: string;
|
||
|
||
// Metadata
|
||
status: string; // "ACTIVE", etc.
|
||
publishedAt: number; // unix timestamp
|
||
projectName: string | null;
|
||
|
||
// Engagement
|
||
viewCount: number;
|
||
saveCount: number;
|
||
|
||
// Amenities & Features
|
||
amenities: string[]; // e.g., ["elevator", "pool", "gym"]
|
||
isFeatured: number; // 1 if featured, 0 otherwise
|
||
}
|
||
```
|
||
|
||
**Search Response** (`SearchResult`):
|
||
```typescript
|
||
{
|
||
hits: ListingDocument[]; // matched listings
|
||
totalFound: number; // total matching results
|
||
page: number; // current page
|
||
perPage: number; // items per page
|
||
totalPages: number;
|
||
searchTimeMs: number; // query time in milliseconds
|
||
}
|
||
```
|
||
|
||
**Supported Filters**:
|
||
- `propertyType`: APARTMENT, VILLA, TOWNHOUSE, LAND, OFFICE, SHOPHOUSE
|
||
- `transactionType`: SALE, RENT
|
||
- `priceVND`: range [min..max] or >= or <=
|
||
- `areaM2`: range [min..max] or >= or <=
|
||
- `bedrooms`: >= operator
|
||
- `district`, `city`: equality
|
||
- `status`: fixed to ACTIVE in search
|
||
- `isFeatured`: 1 or 0
|
||
|
||
---
|
||
|
||
## Prisma Schema Key Models
|
||
|
||
### Property Model
|
||
```prisma
|
||
model Property {
|
||
id String @id @default(cuid())
|
||
propertyType PropertyType
|
||
title String
|
||
description String @db.Text
|
||
address String
|
||
ward String
|
||
district String
|
||
city String
|
||
addressNormalized String? // for duplicate detection
|
||
location geometry(Point, 4326) // PostGIS
|
||
areaM2 Float
|
||
usableAreaM2 Float?
|
||
bedrooms Int?
|
||
bathrooms Int?
|
||
floors Int?
|
||
floor Int?
|
||
totalFloors Int?
|
||
direction Direction?
|
||
yearBuilt Int?
|
||
legalStatus String?
|
||
amenities Json?
|
||
nearbyPOIs Json?
|
||
metroDistanceM Float?
|
||
projectName String?
|
||
projectDevelopmentId String?
|
||
furnishing Furnishing?
|
||
propertyCondition PropertyCondition?
|
||
balconyDirection Direction?
|
||
maintenanceFeeVND BigInt? // CHECK >= 0
|
||
parkingSlots Int?
|
||
viewType String[] @default([])
|
||
petFriendly Boolean?
|
||
suitableFor String[] @default([])
|
||
whyThisLocation String? @db.Text
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
listings Listing[]
|
||
valuations Valuation[]
|
||
media PropertyMedia[]
|
||
|
||
// Indexes for queries
|
||
@@index([propertyType])
|
||
@@index([district, city])
|
||
@@index([location], type: Gist)
|
||
@@index([district, propertyType])
|
||
@@index([district, city, propertyType])
|
||
}
|
||
```
|
||
|
||
### Listing Model
|
||
```prisma
|
||
model Listing {
|
||
id String @id @default(cuid())
|
||
propertyId String
|
||
agentId String?
|
||
sellerId String
|
||
transactionType TransactionType
|
||
status ListingStatus @default(DRAFT)
|
||
priceVND BigInt // CHECK > 0
|
||
pricePerM2 Float?
|
||
rentPriceMonthly BigInt? // CHECK > 0
|
||
commissionPct Float? @default(2.0)
|
||
aiPriceEstimate BigInt? // CHECK > 0
|
||
aiConfidence Float?
|
||
moderationScore Float?
|
||
moderationNotes String?
|
||
viewCount Int @default(0)
|
||
saveCount Int @default(0)
|
||
inquiryCount Int @default(0)
|
||
featuredUntil DateTime?
|
||
expiresAt DateTime?
|
||
publishedAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
transactions Transaction[]
|
||
inquiries Inquiry[]
|
||
orders Order[]
|
||
priceHistories PriceHistory[]
|
||
savedByUsers SavedListing[]
|
||
|
||
@@index([status])
|
||
@@index([transactionType])
|
||
@@index([sellerId, status, publishedAt(sort: Desc)])
|
||
@@index([agentId, status])
|
||
@@index([status, publishedAt(sort: Desc)])
|
||
}
|
||
```
|
||
|
||
### Agent Model
|
||
```prisma
|
||
model Agent {
|
||
id String @id @default(cuid())
|
||
userId String @unique
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
licenseNumber String?
|
||
agency String?
|
||
qualityScore Float @default(0)
|
||
totalDeals Int @default(0)
|
||
responseTimeAvg Int? // seconds
|
||
bio String?
|
||
serviceAreas Json // ["quan-1", "quan-7", "thu-duc"]
|
||
isVerified Boolean @default(false)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
listings Listing[]
|
||
leads Lead[]
|
||
|
||
@@index([qualityScore])
|
||
@@index([isVerified])
|
||
}
|
||
```
|
||
|
||
### Valuation Model (Analytics)
|
||
```prisma
|
||
model Valuation {
|
||
id String @id @default(cuid())
|
||
propertyId String
|
||
property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
|
||
estimatedPrice BigInt
|
||
confidence Float // 0.0-1.0
|
||
pricePerM2 Float
|
||
comparables Json // array of {propertyId, address, priceVND, distance, ...}
|
||
features Json // model input features used
|
||
modelVersion String // "v1" | "v2" | "ensemble"
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([propertyId, createdAt(sort: Desc)])
|
||
}
|
||
```
|
||
|
||
### MarketIndex Model (Analytics)
|
||
```prisma
|
||
model MarketIndex {
|
||
id String @id @default(cuid())
|
||
district String
|
||
city String
|
||
propertyType PropertyType
|
||
period String // e.g., "2026-Q1" or "2026-04"
|
||
medianPrice BigInt
|
||
avgPriceM2 Float
|
||
totalListings Int
|
||
daysOnMarket Float
|
||
inventoryLevel Int
|
||
absorptionRate Float? // listings sold per month
|
||
yoyChange Float? // year-over-year % change
|
||
createdAt DateTime @default(now())
|
||
|
||
@@unique([district, city, propertyType, period])
|
||
@@index([city, period])
|
||
}
|
||
```
|
||
|
||
### User Model (Auth)
|
||
```prisma
|
||
model User {
|
||
id String @id @default(cuid())
|
||
email String?
|
||
phone String
|
||
passwordHash String?
|
||
fullName String
|
||
avatarUrl String?
|
||
role UserRole @default(BUYER) // BUYER | SELLER | AGENT | DEVELOPER | PARK_OPERATOR | ADMIN
|
||
kycStatus KYCStatus @default(NONE) // NONE | PENDING | VERIFIED | REJECTED
|
||
isActive Boolean @default(true)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
agent Agent?
|
||
listings Listing[]
|
||
subscription Subscription?
|
||
payments Payment[]
|
||
|
||
@@index([role])
|
||
@@index([kycStatus])
|
||
@@index([isActive])
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Enums
|
||
|
||
### PropertyType
|
||
```
|
||
APARTMENT, VILLA, TOWNHOUSE, LAND, OFFICE, SHOPHOUSE
|
||
```
|
||
|
||
### TransactionType
|
||
```
|
||
SALE, RENT
|
||
```
|
||
|
||
### ListingStatus
|
||
```
|
||
DRAFT, PENDING_REVIEW, ACTIVE, RESERVED, SOLD, RENTED, EXPIRED, REJECTED
|
||
```
|
||
|
||
### Direction
|
||
```
|
||
NORTH, SOUTH, EAST, WEST, NORTHEAST, NORTHWEST, SOUTHEAST, SOUTHWEST
|
||
```
|
||
|
||
### Furnishing
|
||
```
|
||
FULLY_FURNISHED, BASIC_FURNISHED, UNFURNISHED
|
||
```
|
||
|
||
### PropertyCondition
|
||
```
|
||
NEW, LIKE_NEW, RENOVATED, USED
|
||
```
|
||
|
||
---
|
||
|
||
## Common Patterns
|
||
|
||
### Price Formatting
|
||
All prices in VND are returned as:
|
||
- **strings** in DTOs (to preserve precision with large numbers)
|
||
- **BigInt** in database and some service responses
|
||
- **number** in JavaScript/TS objects (caution: precision loss above 2⁵³)
|
||
|
||
**Display Pattern**: Format with `Intl.NumberFormat` or custom VND formatter
|
||
|
||
### Pagination
|
||
Search results include:
|
||
```typescript
|
||
{
|
||
page: number;
|
||
perPage: number;
|
||
totalPages: number;
|
||
totalFound: number;
|
||
}
|
||
```
|
||
|
||
### Caching Headers
|
||
Responses include cache metadata:
|
||
```typescript
|
||
{
|
||
cachedAt: string | null; // ISO timestamp of cache retrieval
|
||
nextRefreshAt: string | null; // ISO timestamp of next refresh
|
||
}
|
||
```
|
||
|
||
### Confidence Scores
|
||
- **Valuation confidence**: 0.0 - 1.0 (higher = more reliable)
|
||
- **Moderation score**: 0-100 (higher = better quality)
|
||
- **Quality score**: 0.0-100.0 (agent performance)
|
||
|
||
### Rate Limits
|
||
- **Analytics queries**: 100 req/min per subscription tier
|
||
- **Valuation POST**: 10 req/min per user
|
||
- **Search**: 200 req/min per user
|
||
- **Batch operations**: Limited to 50 items per request
|
||
|
||
---
|
||
|
||
## Error Responses
|
||
|
||
All endpoints return consistent error format:
|
||
```typescript
|
||
{
|
||
statusCode: number;
|
||
message: string;
|
||
error?: string; // error type
|
||
details?: Record<string, any>; // field-level errors
|
||
timestamp: string; // ISO timestamp
|
||
path: string; // request path
|
||
}
|
||
```
|
||
|
||
**Common Codes**:
|
||
- `400`: Invalid parameters (missing required fields, validation errors)
|
||
- `401`: Unauthorized (no/invalid JWT token)
|
||
- `403`: Forbidden (quota exceeded, permission denied)
|
||
- `404`: Not found (property/listing/agent not found)
|
||
- `429`: Rate limit exceeded
|
||
- `500`: Server error
|
||
- `503`: Service unavailable (e.g., AI service down)
|
||
|
||
---
|
||
|
||
## References
|
||
|
||
**Generated from**:
|
||
- `apps/api/src/modules/analytics/` — Market data, valuations, neighborhood scores
|
||
- `apps/api/src/modules/listings/` — Property and listing entities
|
||
- `apps/api/src/modules/agents/` — Agent profiles and quality scores
|
||
- `apps/api/src/modules/search/` — Search indexing and retrieval
|
||
- `prisma/schema.prisma` — Database models and relationships
|
||
|
||
**Last Updated**: April 2026
|
||
|