Files
goodgo-platform/docs/explorations/LISTINGS_DATA_SCHEMA.md
Ho Ngoc Hai 08b96f9c2d 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>
2026-04-21 16:29:24 +07:00

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 viewed
  • saveCount — incremented when saved/bookmarked
  • inquiryCount — incremented when InquiryCreatedEvent published

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 InquiryCreatedEvent published, 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),
);

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