- 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>
28 KiB
GoodGo Platform — API Endpoints & Data Fields Reference
For UI Component Mapping
Generated: April 2026
Table of Contents
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:
{
city: string; // e.g., "HCMC", "Hanoi"
period?: string; // time period identifier
propertyType?: PropertyType; // APARTMENT | VILLA | TOWNHOUSE | LAND | OFFICE | SHOPHOUSE
}
Response (MarketReportDto):
{
// 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:
{
city: string; // e.g., "HCMC"
propertyType?: PropertyType; // optional filter
}
Response (MarketSnapshotDto):
{
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:
{
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):
{
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:
{
city: string; // e.g., "HCMC"
period: string; // e.g., "2026-04"
}
Response (HeatmapDto):
{
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:
{
city: string; // e.g., "HCMC"
period: string; // e.g., "2026-04"
}
Response (DistrictStatsDto):
{
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:
{
period?: number; // days (default: 30)
limit?: number; // max areas to return (default: 10)
level?: string; // "district" | "ward" (default: "district")
}
Response (TrendingAreasDto):
{
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:
{
propertyId?: string; // if querying existing property
latitude?: number; // if querying coordinates
longitude?: number;
areaM2?: number; // area required if no propertyId
propertyType?: PropertyType;
}
Response (ValuationDto):
{
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):
{
// ── 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:
{
propertyIds: string[]; // 1-50 property IDs
}
Response:
{
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:
{
limit?: number; // default: 50, max: 365
}
Response:
{
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:
{
propertyIds: string[]; // 2-5 property IDs
}
Response:
{
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:
{
city?: string; // default: "Hồ Chí Minh"
}
Response (NeighborhoodScoreResult):
{
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:
{
lat: number; // latitude (required)
lng: number; // longitude (required)
radius?: number; // meters (default: 2000)
limit?: number; // max results (default: 30)
}
Response (NearbyPOIsResultDto):
{
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
{
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,moderationScoreif available
Property Entity Fields
Entity: PropertyEntity
{
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
{
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:
{
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):
{
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):
{
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, SHOPHOUSEtransactionType: SALE, RENTpriceVND: range [min..max] or >= or <=areaM2: range [min..max] or >= or <=bedrooms: >= operatordistrict,city: equalitystatus: fixed to ACTIVE in searchisFeatured: 1 or 0
Prisma Schema Key Models
Property Model
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
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
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)
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)
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)
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:
{
page: number;
perPage: number;
totalPages: number;
totalFound: number;
}
Caching Headers
Responses include cache metadata:
{
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:
{
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 exceeded500: Server error503: Service unavailable (e.g., AI service down)
References
Generated from:
apps/api/src/modules/analytics/— Market data, valuations, neighborhood scoresapps/api/src/modules/listings/— Property and listing entitiesapps/api/src/modules/agents/— Agent profiles and quality scoresapps/api/src/modules/search/— Search indexing and retrievalprisma/schema.prisma— Database models and relationships
Last Updated: April 2026