# 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.