- 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>
13 KiB
13 KiB
Listings Module - Data Schema & Relationships
Relevant Database Tables
1. Listing Table
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 viewedsaveCount— incremented when saved/bookmarkedinquiryCount— incremented whenInquiryCreatedEventpublished
2. Property Table
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:
-- 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
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:
media: {
orderBy: { order: 'asc' },
take: 10, // Max 10 in detail view
}
4. Inquiry Table
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
InquiryCreatedEventpublished, event listener queries:const count = await prisma.inquiry.count({ where: { listingId } }); await prisma.listing.update({ where: { id: listingId }, data: { inquiryCount: count } });
5. Agent Table
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:
// 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
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
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)
// 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
// 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
const inquiryCount = await prisma.inquiry.count({
where: { listingId },
});
Recalculate Agent Quality Score
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
- GIST on
- 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.