docs: consolidate exploration & audit reports under docs/ (TEC-3094)
- 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>
This commit is contained in:
456
docs/explorations/LISTINGS_DATA_SCHEMA.md
Normal file
456
docs/explorations/LISTINGS_DATA_SCHEMA.md
Normal file
@@ -0,0 +1,456 @@
|
||||
# Listings Module - Data Schema & Relationships
|
||||
|
||||
## Relevant Database Tables
|
||||
|
||||
### 1. Listing Table
|
||||
```sql
|
||||
CREATE TABLE "Listing" (
|
||||
id VARCHAR(25) PRIMARY KEY, -- CUID
|
||||
propertyId VARCHAR(25) NOT NULL, -- FK to Property
|
||||
sellerId VARCHAR(25) NOT NULL, -- FK to User
|
||||
agentId VARCHAR(25) NULL, -- FK to Agent (nullable)
|
||||
transactionType ENUM NOT NULL, -- 'SALE' | 'RENT'
|
||||
status ENUM NOT NULL DEFAULT 'DRAFT', -- status FSM
|
||||
|
||||
-- Price & Commission
|
||||
priceVND BIGINT NOT NULL, -- ✓ Stored as string in DTO
|
||||
pricePerM2 INTEGER NULL, -- Cached on write
|
||||
rentPriceMonthly BIGINT NULL, -- For rentals
|
||||
commissionPct DECIMAL(5,2) NULL, -- Agent commission %
|
||||
|
||||
-- AI/AVM
|
||||
aiPriceEstimate BIGINT NULL, -- Last valuation
|
||||
aiConfidence DECIMAL(3,2) NULL, -- 0.0-1.0
|
||||
moderationScore INTEGER NULL, -- 0-100
|
||||
moderationNotes TEXT NULL,
|
||||
|
||||
-- Engagement Metrics (DENORMALIZED)
|
||||
viewCount INTEGER DEFAULT 0, -- Incremented on view
|
||||
saveCount INTEGER DEFAULT 0, -- Incremented on save
|
||||
inquiryCount INTEGER DEFAULT 0, -- ✓ Incremented by inquiry handler
|
||||
|
||||
-- Featured
|
||||
featuredUntil TIMESTAMP NULL, -- Featured expiry (for isFeatured logic)
|
||||
expiresAt TIMESTAMP NULL, -- Listing expiry
|
||||
publishedAt TIMESTAMP NULL, -- Goes ACTIVE → publishedAt set
|
||||
|
||||
-- Audit
|
||||
createdAt TIMESTAMP DEFAULT NOW(),
|
||||
updatedAt TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
FOREIGN KEY (propertyId) REFERENCES "Property"(id),
|
||||
FOREIGN KEY (sellerId) REFERENCES "User"(id),
|
||||
FOREIGN KEY (agentId) REFERENCES "Agent"(id) ON DELETE SET NULL,
|
||||
|
||||
INDEX (propertyId),
|
||||
INDEX (sellerId),
|
||||
INDEX (agentId),
|
||||
INDEX (status),
|
||||
INDEX (publishedAt DESC),
|
||||
INDEX (featuredUntil DESC, publishedAt DESC), -- Search sort
|
||||
);
|
||||
```
|
||||
|
||||
**denormalized fields (updated via event handlers):**
|
||||
- `viewCount` — incremented when viewed
|
||||
- `saveCount` — incremented when saved/bookmarked
|
||||
- `inquiryCount` — incremented when `InquiryCreatedEvent` published
|
||||
|
||||
---
|
||||
|
||||
### 2. Property Table
|
||||
```sql
|
||||
CREATE TABLE "Property" (
|
||||
id VARCHAR(25) PRIMARY KEY, -- CUID
|
||||
|
||||
-- Location (PostGIS)
|
||||
location GEOMETRY(Point, 4326) NOT NULL, -- ST_GeomFromText('POINT(lng lat)')
|
||||
latitude DECIMAL(10,8) NOT NULL,
|
||||
longitude DECIMAL(11,8) NOT NULL,
|
||||
|
||||
-- Address
|
||||
address VARCHAR(255) NOT NULL,
|
||||
ward VARCHAR(100) NOT NULL,
|
||||
district VARCHAR(100) NOT NULL,
|
||||
city VARCHAR(100) NOT NULL,
|
||||
|
||||
-- Type & Dimensions
|
||||
propertyType ENUM NOT NULL, -- 'apartment', 'house', 'land', etc.
|
||||
areaM2 DECIMAL(10,2) NOT NULL,
|
||||
usableAreaM2 DECIMAL(10,2) NULL,
|
||||
|
||||
-- Building
|
||||
bedrooms INTEGER NULL,
|
||||
bathrooms INTEGER NULL,
|
||||
floors INTEGER NULL, -- Number of separate floors
|
||||
floor INTEGER NULL, -- Which floor (for apartments)
|
||||
totalFloors INTEGER NULL, -- Total floors in building
|
||||
direction VARCHAR(50) NULL, -- 'north', 'south', 'east', 'west'
|
||||
yearBuilt INTEGER NULL,
|
||||
|
||||
-- Legal & Status
|
||||
legalStatus VARCHAR(50) NULL, -- 'SO_DO', 'SO_HONG', 'TMP', etc.
|
||||
projectName VARCHAR(255) NULL,
|
||||
|
||||
-- JSON arrays
|
||||
amenities JSONB NULL, -- ["gym", "pool", "parking", ...]
|
||||
nearbyPOIs JSONB NULL, -- [{ name, type, distance }, ...]
|
||||
metroDistanceM INTEGER NULL,
|
||||
|
||||
-- Descriptors (optional)
|
||||
furnishing VARCHAR(50) NULL, -- 'UNFURNISHED', 'PARTIAL', 'FULL'
|
||||
propertyCondition VARCHAR(50) NULL, -- 'NEW', 'GOOD', 'FAIR', 'POOR'
|
||||
balconyDirection VARCHAR(50) NULL,
|
||||
maintenanceFeeVND BIGINT NULL,
|
||||
parkingSlots INTEGER NULL,
|
||||
viewType JSONB NULL, -- ["street", "garden", "river", ...]
|
||||
petFriendly BOOLEAN NULL,
|
||||
suitableFor JSONB NULL, -- ["families", "students", ...]
|
||||
whyThisLocation VARCHAR(1000) NULL, -- Seller's narrative
|
||||
|
||||
-- Audit
|
||||
createdAt TIMESTAMP DEFAULT NOW(),
|
||||
updatedAt TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
INDEX (location) USING GIST, -- PostGIS spatial index
|
||||
INDEX (district),
|
||||
INDEX (city),
|
||||
INDEX (propertyType),
|
||||
);
|
||||
```
|
||||
|
||||
**PostGIS Queries:**
|
||||
```sql
|
||||
-- Extract latitude/longitude
|
||||
SELECT
|
||||
ST_Y(location::geometry) AS latitude,
|
||||
ST_X(location::geometry) AS longitude
|
||||
FROM "Property"
|
||||
WHERE id = $1;
|
||||
|
||||
-- Radius search (comparables)
|
||||
SELECT * FROM "Property"
|
||||
WHERE ST_DWithin(location, ST_GeomFromText('POINT(lng lat)', 4326)::geography, 2000)
|
||||
AND propertyType = $type
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. PropertyMedia Table
|
||||
```sql
|
||||
CREATE TABLE "PropertyMedia" (
|
||||
id VARCHAR(25) PRIMARY KEY, -- CUID
|
||||
propertyId VARCHAR(25) NOT NULL, -- FK to Property
|
||||
|
||||
url VARCHAR(1024) NOT NULL, -- CDN URL
|
||||
type ENUM NOT NULL, -- 'image' | 'video'
|
||||
order INTEGER DEFAULT 0, -- Display order
|
||||
caption VARCHAR(500) NULL, -- Optional caption
|
||||
|
||||
-- Audit
|
||||
createdAt TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
FOREIGN KEY (propertyId) REFERENCES "Property"(id) ON DELETE CASCADE,
|
||||
INDEX (propertyId),
|
||||
INDEX (propertyId, order ASC), -- Efficient media ordering
|
||||
);
|
||||
```
|
||||
|
||||
**Fetching in queries:**
|
||||
```typescript
|
||||
media: {
|
||||
orderBy: { order: 'asc' },
|
||||
take: 10, // Max 10 in detail view
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Inquiry Table
|
||||
```sql
|
||||
CREATE TABLE "Inquiry" (
|
||||
id VARCHAR(25) PRIMARY KEY, -- CUID
|
||||
listingId VARCHAR(25) NOT NULL, -- FK to Listing
|
||||
userId VARCHAR(25) NOT NULL, -- FK to User (inquirer)
|
||||
|
||||
message TEXT NOT NULL, -- Sanitized HTML
|
||||
phone VARCHAR(20) NULL, -- Alternate contact
|
||||
isRead BOOLEAN DEFAULT FALSE, -- Seller/agent marked read?
|
||||
|
||||
-- Audit
|
||||
createdAt TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
FOREIGN KEY (listingId) REFERENCES "Listing"(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (userId) REFERENCES "User"(id) ON DELETE CASCADE,
|
||||
INDEX (listingId),
|
||||
INDEX (userId),
|
||||
INDEX (createdAt DESC),
|
||||
);
|
||||
```
|
||||
|
||||
**inquiryCount Denormalization:**
|
||||
- When `InquiryCreatedEvent` published, event listener queries:
|
||||
```typescript
|
||||
const count = await prisma.inquiry.count({ where: { listingId } });
|
||||
await prisma.listing.update({
|
||||
where: { id: listingId },
|
||||
data: { inquiryCount: count }
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Agent Table
|
||||
```sql
|
||||
CREATE TABLE "Agent" (
|
||||
id VARCHAR(25) PRIMARY KEY, -- CUID
|
||||
userId VARCHAR(25) NOT NULL UNIQUE, -- FK to User
|
||||
|
||||
-- Profile
|
||||
agency VARCHAR(255) NULL,
|
||||
licenseNumber VARCHAR(50) NULL,
|
||||
bio VARCHAR(1000) NULL,
|
||||
|
||||
-- Quality & Performance (AGGREGATES)
|
||||
qualityScore DECIMAL(5,2) DEFAULT 50, -- ✓ Stored here; calc'd from metrics
|
||||
totalDeals INTEGER DEFAULT 0,
|
||||
isVerified BOOLEAN DEFAULT FALSE,
|
||||
|
||||
-- Service Areas (JSONB array)
|
||||
serviceAreas JSONB NULL, -- ["Hoang Mai", "Cau Giay", ...]
|
||||
|
||||
-- Audit
|
||||
createdAt TIMESTAMP DEFAULT NOW(),
|
||||
updatedAt TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
FOREIGN KEY (userId) REFERENCES "User"(id) ON DELETE CASCADE,
|
||||
INDEX (qualityScore DESC), -- Sorting by quality
|
||||
);
|
||||
```
|
||||
|
||||
**qualityScore Calculation Source Data:**
|
||||
```typescript
|
||||
// Aggregate query for recalculation
|
||||
const stats = await Promise.all([
|
||||
prisma.review.aggregate({
|
||||
where: { targetType: 'AGENT', targetId: agentId },
|
||||
_avg: { rating: true },
|
||||
_count: { rating: true },
|
||||
}),
|
||||
|
||||
prisma.inquiry.aggregate({
|
||||
where: { listing: { agentId } },
|
||||
_count: { id: true },
|
||||
}),
|
||||
|
||||
prisma.listing.aggregate({
|
||||
where: { agentId, status: 'ACTIVE' },
|
||||
_count: { id: true },
|
||||
}),
|
||||
|
||||
// Response time calc (from inquiry timestamps)
|
||||
prisma.inquiry.aggregate({
|
||||
where: { listing: { agentId } },
|
||||
// ... calculate avg time to response/resolution
|
||||
}),
|
||||
]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Review Table
|
||||
```sql
|
||||
CREATE TABLE "Review" (
|
||||
id VARCHAR(25) PRIMARY KEY, -- CUID
|
||||
|
||||
targetType ENUM NOT NULL, -- 'AGENT' | 'SELLER'
|
||||
targetId VARCHAR(25) NOT NULL, -- Agent/Seller ID
|
||||
userId VARCHAR(25) NOT NULL, -- Reviewer
|
||||
|
||||
rating INTEGER NOT NULL, -- 1-5 (for avgRating calc)
|
||||
title VARCHAR(255) NULL,
|
||||
content TEXT NULL,
|
||||
|
||||
createdAt TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
FOREIGN KEY (targetId) REFERENCES "User"(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (userId) REFERENCES "User"(id) ON DELETE CASCADE,
|
||||
INDEX (targetType, targetId, createdAt DESC),
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Tables (Reference)
|
||||
|
||||
### User Table
|
||||
```sql
|
||||
CREATE TABLE "User" (
|
||||
id VARCHAR(25) PRIMARY KEY, -- CUID
|
||||
fullName VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
phone VARCHAR(20) NOT NULL,
|
||||
avatarUrl VARCHAR(1024) NULL,
|
||||
createdAt TIMESTAMP DEFAULT NOW(),
|
||||
...
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Query Patterns
|
||||
|
||||
### Get Listing Detail (with PostGIS)
|
||||
```typescript
|
||||
// 1. Main listing query (with relations)
|
||||
const listing = await prisma.listing.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
property: {
|
||||
include: {
|
||||
media: { orderBy: { order: 'asc' }, take: 10 },
|
||||
},
|
||||
},
|
||||
seller: { select: { id, fullName, phone } },
|
||||
agent: { select: { id, userId, agency } },
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Extract geometry (PostGIS)
|
||||
const geoRows = await prisma.$queryRaw`
|
||||
SELECT
|
||||
ST_Y("location"::geometry) AS latitude,
|
||||
ST_X("location"::geometry) AS longitude
|
||||
FROM "Property"
|
||||
WHERE "id" = ${listing.property.id}
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
// 3. Combine into ListingDetailData
|
||||
return {
|
||||
...listing,
|
||||
property: {
|
||||
...listing.property,
|
||||
latitude: geoRows[0].latitude,
|
||||
longitude: geoRows[0].longitude,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Find Similar Listings
|
||||
```typescript
|
||||
// Price ±10%, area ±20%, same type/district
|
||||
const candidates = await prisma.listing.findMany({
|
||||
where: {
|
||||
id: { not: sourceId },
|
||||
status: 'ACTIVE',
|
||||
priceVND: { gte: minPrice, lte: maxPrice },
|
||||
property: {
|
||||
propertyType: sourcePropertyType,
|
||||
district: sourceDistrict,
|
||||
areaM2: { gte: minArea, lte: maxArea },
|
||||
},
|
||||
},
|
||||
orderBy: { priceVND: 'asc' },
|
||||
take: limit * 3,
|
||||
include: {
|
||||
property: {
|
||||
include: { media: { orderBy: { order: 'asc' }, take: 1 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Count Inquiries by Listing
|
||||
```typescript
|
||||
const inquiryCount = await prisma.inquiry.count({
|
||||
where: { listingId },
|
||||
});
|
||||
```
|
||||
|
||||
### Recalculate Agent Quality Score
|
||||
```typescript
|
||||
const [reviews, listings, inquiries] = await Promise.all([
|
||||
prisma.review.aggregate({
|
||||
where: { targetType: 'AGENT', targetId: agentId },
|
||||
_avg: { rating: true },
|
||||
_count: { rating: true },
|
||||
}),
|
||||
prisma.listing.findMany({
|
||||
where: { agentId },
|
||||
select: { id: true, status: true },
|
||||
}),
|
||||
prisma.inquiry.findMany({
|
||||
where: { listing: { agentId } },
|
||||
include: { listing: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const inputs = {
|
||||
avgRating: reviews._avg.rating || 3.0,
|
||||
totalReviews: reviews._count.rating,
|
||||
responseTimeAvg: calculateResponseTime(inquiries),
|
||||
conversionRate: calculateConversion(inquiries, listings),
|
||||
activeListingRatio: listings.filter(l => l.status === 'ACTIVE').length / listings.length,
|
||||
};
|
||||
|
||||
const newScore = QualityScoreCalculator.calculate(inputs);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cache Invalidation Triggers
|
||||
|
||||
| Event | Invalidation |
|
||||
|-------|-------------|
|
||||
| Listing status changes (DRAFT → PENDING → ACTIVE) | `cache:listing:{id}` |
|
||||
| Listing price updates | `cache:listing:{id}` + `cache:search:*` |
|
||||
| Inquiry created | No listing cache invalidation (read-only counter) |
|
||||
| Review created (agent) | Regenerate Agent quality score (stored in DB) |
|
||||
| Featured status changes | `cache:listing:{id}` + `cache:search:*` |
|
||||
| Property media upload | `cache:listing:{id}` |
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Indexes
|
||||
- **Listing:**
|
||||
- `(status, publishedAt DESC)` — search & sort
|
||||
- `(featuredUntil DESC, publishedAt DESC)` — featured listings first
|
||||
- `(agentId, status)` — agent listings
|
||||
- **Property:**
|
||||
- **GIST on `location`** — PostGIS radius queries
|
||||
- `(district, city)` — filtering
|
||||
- `(propertyType)` — type filtering
|
||||
- **PropertyMedia:**
|
||||
- `(propertyId, order ASC)` — fetch ordered media
|
||||
- **Inquiry:**
|
||||
- `(listingId)` — count by listing
|
||||
- `(userId)` — inquiries by user
|
||||
- **Review:**
|
||||
- `(targetType, targetId, createdAt DESC)` — agent reviews
|
||||
- **Agent:**
|
||||
- `(qualityScore DESC)` — sorting agents by quality
|
||||
|
||||
### Query Optimization
|
||||
- **Batch geo extraction:** Fetch multiple properties' coordinates in one query
|
||||
- **Media fetch limit:** Take 1 in search, 10 in detail (avoid N+1)
|
||||
- **Denormalized counters:** inquiryCount, viewCount, saveCount avoid expensive COUNTs
|
||||
- **Cached quality scores:** Agent qualityScore stored, not calculated on each request
|
||||
|
||||
---
|
||||
|
||||
## Denormalization Strategy
|
||||
|
||||
| Field | Table | Purpose | Update Mechanism |
|
||||
|-------|-------|---------|------------------|
|
||||
| `viewCount` | Listing | Track popularity | Event listener on view event |
|
||||
| `saveCount` | Listing | Track saves | Event listener on save event |
|
||||
| `inquiryCount` | Listing | Display inquiry badge | Event listener on `InquiryCreatedEvent` |
|
||||
| `pricePerM2` | Listing | Sort/filter | Calculated on listing creation/price update |
|
||||
| `qualityScore` | Agent | Sort/filter agents | Recalculation command (triggered by review/inquiry events) |
|
||||
|
||||
**Consistency Model:** Eventual consistency via event handlers; counters may lag by seconds.
|
||||
|
||||
Reference in New Issue
Block a user