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

