# Listings Module Exploration & Architecture

**Date:** April 21, 2026  
**Project:** goodgo-platform-ai  
**Scope:** GET /listings/:id handler, response DTOs, AVM service integration, agent quality scores, inquiries, similar listings, and caching patterns

---

## 1. GET /listings/:id Handler (Application Layer)

### File Path
`apps/api/src/modules/listings/presentation/controllers/listings.controller.ts`

### Handler Definition (Line 236-247)
```typescript
@ApiOperation({ summary: 'Get listing details by ID' })
@ApiParam({ name: 'id', description: 'Listing UUID', example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' })
@ApiResponse({ status: 200, description: 'Listing details returned' })
@ApiResponse({ status: 404, description: 'Listing not found' })
@Get(':id')
async getListing(@Param('id') id: string): Promise<ListingDetailData> {
  const result = await this.queryBus.execute(new GetListingQuery(id));
  if (!result) {
    throw new NotFoundException('Listing', id);
  }
  return result;
}
```

### Query Handler
**File:** `apps/api/src/modules/listings/application/queries/get-listing/get-listing.handler.ts`

**Key Features:**
- Implements `IQueryHandler<GetListingQuery>`
- Uses **cache-aside pattern** with Redis
- Cache key: `CacheService.buildKey(CachePrefix.LISTING, query.listingId)`
- Cache TTL: **300 seconds (5 minutes)** — `CacheTTL.LISTING_DETAIL`
- On cache miss, fetches via `listingRepo.findByIdWithProperty(query.listingId)`
- Returns `ListingDetailData | null`
- **Not-found signal:** Uses internal `ListingNotFoundSignal` to avoid caching null results, allowing subsequent requests to find newly-created listings

```typescript
@QueryHandler(GetListingQuery)
export class GetListingHandler implements IQueryHandler<GetListingQuery> {
  constructor(
    @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
    private readonly cache: CacheService,
    private readonly logger: LoggerService,
  ) {}

  async execute(query: GetListingQuery): Promise<ListingDetailData | null> {
    try {
      const cacheKey = CacheService.buildKey(CachePrefix.LISTING, query.listingId);
      const cached = await this.cache.getOrSet<ListingDetailData | null>(
        cacheKey,
        async () => {
          const result = await this.listingRepo.findByIdWithProperty(query.listingId);
          if (!result) {
            throw new ListingNotFoundSignal();
          }
          return result;
        },
        CacheTTL.LISTING_DETAIL,
        'listing',
      );
      return cached;
    } catch (error) {
      if (error instanceof ListingNotFoundSignal) return null;
      if (error instanceof DomainException) throw error;
      // Error handling & logging...
      throw new InternalServerErrorException('Không thể lấy thông tin tin đăng');
    }
  }
}
```

---

## 2. Response DTO / Schema

### File Path
`apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts`

### ListingDetailData Interface
```typescript
export interface ListingDetailData {
  id: string;
  status: ListingStatus;
  transactionType: TransactionType;
  priceVND: string;
  pricePerM2: number | null;
  rentPriceMonthly: string | null;
  commissionPct: number | null;
  
  // ─── Engagement Metrics ───────────────────────────────────────
  viewCount: number;
  saveCount: number;
  inquiryCount: number;           // ← TRACKED HERE (see Section 5)
  
  // ─── Featured Status ──────────────────────────────────────────
  isFeatured: boolean;
  featuredUntil: string | null;   // ISO 8601
  publishedAt: string | null;     // ISO 8601
  createdAt: string;              // ISO 8601

  // ─── Property Details ─────────────────────────────────────────
  property: {
    id: string;
    propertyType: PropertyType;
    title: string;
    description: string;
    address: string;
    ward: string;
    district: string;
    city: string;
    latitude: number;
    longitude: number;
    areaM2: number;
    usableAreaM2: number | null;
    bedrooms: number | null;
    bathrooms: number | null;
    floors: number | null;
    floor: number | null;
    totalFloors: number | null;
    direction: Direction | null;
    yearBuilt: number | null;
    legalStatus: string | null;
    amenities: unknown;           // JSON array
    nearbyPOIs: unknown;           // JSON array
    metroDistanceM: number | null;
    projectName: string | null;
    furnishing: Furnishing | null;
    propertyCondition: PropertyCondition | null;
    balconyDirection: Direction | null;
    maintenanceFeeVND: string | null;
    parkingSlots: number | null;
    viewType: string[];            // Array of view types
    petFriendly: boolean | null;
    suitableFor: string[];         // Array of suitable demographics
    whyThisLocation: string | null;
    media: ListingMediaData[];     // Up to 10 images/videos
  };

  // ─── Seller & Agent Info ─────────────────────────────────────
  seller: {
    id: string;
    fullName: string;
    phone: string;
  };
  agent: {
    id: string;
    userId: string;
    agency: string | null;
  } | null;
}

export interface ListingMediaData {
  id: string;
  url: string;
  type: string;              // 'image' | 'video'
  order: number;
  caption: string | null;
}
```

### Other Related DTOs
- **ListingSearchItem:** Compact summary with thumbnail for search results
- **ListingSimilarItem:** Ultra-compact for "similar listings" widget
- **ListingSellerItem:** For seller dashboard

---

## 3. AVM Service Integration

### Service Interface
**File:** `apps/api/src/modules/analytics/domain/services/avm-service.ts`

```typescript
export interface AVMParams {
  // ─── Location & Property Base ─────────────────────────────
  propertyId?: string;          // If provided, property details are fetched from DB
  latitude?: number;
  longitude?: number;
  areaM2?: number;
  propertyType?: PropertyType;
  yearBuilt?: number;
  floor?: number;
  totalFloors?: number;

  // ─── Optional Inline Descriptors ──────────────────────────
  // (Used when no propertyId is given)
  district?: string;
  city?: string;
  bedrooms?: number;
  bathrooms?: number;
  floors?: number;
  frontage?: number;
  roadWidth?: number;
  hasLegalPaper?: boolean;
  projectId?: string;
  imageUrl?: string;
  description?: string;
  deepAnalysis?: boolean;

  // ─── AVM v2 Extended Features ─────────────────────────────
  useV2?: boolean;              // Use enhanced model
  distanceToHospitalKm?: number;
  distanceToParkKm?: number;
  distanceToMallKm?: number;
  floodZoneRisk?: FloodZoneRisk; // 'NONE' | 'LOW' | 'MEDIUM' | 'HIGH'
  hasElevator?: boolean;
  hasParking?: boolean;
  hasPool?: boolean;
}

export interface ValuationResult {
  estimatedPrice: string;       // VND
  confidence: number;           // 0-1
  pricePerM2: number;          // VND per m²
  comparables: Comparable[];    // Transaction comparables
  modelVersion: string;         // 'ai-service-v1.0' or 'ai-service-v2'
  confidenceExplanation?: string;
}

export interface Comparable {
  propertyId: string;
  address: string;
  district: string;
  priceVND: string;
  pricePerM2: number;
  areaM2: number;
  propertyType: PropertyType;
  distanceMeters: number;
  soldAt: string;               // ISO 8601
}
```

### Implementation: HttpAVMService
**File:** `apps/api/src/modules/analytics/infrastructure/services/http-avm.service.ts`

**Key Architecture:**
- Primary: Calls AI service (Python microservice) via HTTP
- Fallback: PrismaAVMService (comparables-based estimation using PostGIS)
- Batch processing: Max concurrency **5** to avoid overloading Python service

```typescript
@Injectable()
export class HttpAVMService implements IAVMService {
  constructor(
    @Inject(AI_SERVICE_CLIENT) private readonly aiClient: IAiServiceClient,
    private readonly fallback: PrismaAVMService,
    private readonly prisma: PrismaService,
    private readonly logger: LoggerService,
  ) {}

  async estimateValue(params: AVMParams): Promise<ValuationResult> {
    try {
      return await this.estimateViaAi(params);
    } catch (err) {
      // Fallback to comparables-based estimation
      this.logger.warn(
        `AI AVM service unavailable, falling back to comparables-based estimation: ${(err as Error).message}`,
        'HttpAVMService',
      );
      return this.fallback.estimateValue(params);
    }
  }

  async getComparables(propertyId: string, radiusMeters: number): Promise<Comparable[]> {
    return this.fallback.getComparables(propertyId, radiusMeters);
  }

  async estimateBatch(items: BatchValuationItem[]): Promise<BatchValuationResult[]> {
    // Processes in chunks with max concurrency 5
  }
}
```

**estimateViaAi Method (Simplified):**
```typescript
private async estimateViaAi(params: AVMParams): Promise<ValuationResult> {
  // Fetch property details if propertyId is provided
  const propertyData = params.propertyId
    ? await this.getPropertyDetails(params.propertyId)
    : null;

  if (params.useV2) {
    return this.estimateViaAiV2(params, propertyData);
  }

  // V1 Request to AI service
  const request: AiPredictRequest = {
    area: params.areaM2 ?? propertyData?.areaM2 ?? 0,
    district: params.district ?? propertyData?.district ?? '',
    city: params.city ?? propertyData?.city ?? '',
    property_type: (params.propertyType ?? propertyData?.propertyType ?? 'house').toLowerCase(),
    bedrooms: params.bedrooms ?? propertyData?.bedrooms ?? 0,
    bathrooms: params.bathrooms ?? propertyData?.bathrooms ?? 0,
    floors: params.floors ?? propertyData?.floors ?? 0,
    frontage: params.frontage ?? 0,
    road_width: params.roadWidth ?? 0,
    year_built: params.yearBuilt ?? propertyData?.yearBuilt,
    has_legal_paper: params.hasLegalPaper ?? propertyData?.hasLegalPaper ?? true,
  };

  const aiResult = await this.aiClient.predict(request);

  // Fetch comparables for context
  let comparables: Comparable[] = [];
  try {
    if (params.propertyId) {
      comparables = await this.fallback.getComparables(params.propertyId, 2000); // 2km radius
    }
  } catch {
    // Comparables are supplementary
  }

  return {
    estimatedPrice: Math.round(aiResult.estimated_price_vnd).toString(),
    confidence: aiResult.confidence,
    pricePerM2: Math.round(aiResult.price_per_m2),
    comparables,
    modelVersion: 'ai-service-v1.0',
  };
}
```

### AI Service Client
**File:** `apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts`

**Request/Response Interfaces:**

```typescript
export interface AiPredictRequest {
  area: number;
  district: string;
  city: string;
  property_type: string;
  bedrooms?: number;
  bathrooms?: number;
  floors?: number;
  frontage?: number;
  road_width?: number;
  year_built?: number | null;
  has_legal_paper?: boolean;
}

export interface AiPredictResponse {
  estimated_price_vnd: number;
  confidence: number;
  price_per_m2: number;
  price_range_low: number;
  price_range_high: number;
}

// AVM v2 — extended features
export interface AiPredictV2Request {
  district: string;
  city: string;
  property_type: string;
  area_m2: number;
  distance_to_hospital_km?: number;
  distance_to_park_km?: number;
  distance_to_mall_km?: number;
  flood_zone_risk?: number;        // 0-1 scale
  rooms?: number;
  total_floors?: number;
  building_age_years?: number;
  has_elevator?: boolean;
  has_parking?: boolean;
  has_pool?: boolean;
  has_legal_paper?: boolean;
  month?: number;
  quarter?: number;
  is_year_end?: boolean;
}

export interface AiPredictV2Response {
  estimated_price_vnd: number;
  confidence: number;
  price_per_m2_vnd: number;
  price_range_low_vnd: number;
  price_range_high_vnd: number;
  drivers?: AiPredictV2FeatureImportance[];
  comparables?: AiPredictV2Comparable[];
  model_version?: string;
  ensemble_method?: string;
}
```

---

## 4. Agent Quality Score

### Storage Location
**File:** `apps/api/src/modules/agents/infrastructure/repositories/agent-profile.queries.ts` (line 103)

```typescript
export async function buildPublicProfile(
  prisma: PrismaService,
  agentId: string,
): Promise<AgentPublicProfileData | null> {
  const agent = await prisma.agent.findUnique({
    where: { id: agentId },
    include: {
      user: {
        select: {
          fullName: true,
          avatarUrl: true,
          phone: true,
          email: true,
          createdAt: true,
        },
      },
    },
  });

  if (!agent) return null;

  // qualityScore is a field on the agent record
  return {
    // ...
    qualityScore: agent.qualityScore,  // ← Stored here
    totalDeals: agent.totalDeals,
    isVerified: agent.isVerified,
    // ...
  };
}
```

### Calculation Service
**File:** `apps/api/src/modules/agents/domain/services/quality-score.service.ts`

**Pure Domain Service (no infrastructure dependencies):**

```typescript
export class QualityScoreCalculator {
  /**
   * Quality Score = weighted average of:
   * - Review rating (40%)        — avg rating normalized to 0-100
   * - Response time (30%)        — inverse of avg response time, 0-100
   * - Lead conversion (20%)      — conversion rate * 100
   * - Listing activity (10%)     — active listings ratio * 100
   */
  static calculate(params: {
    avgRating: number;            // 0-5
    totalReviews: number;
    responseTimeAvg: number | null; // seconds
    conversionRate: number;       // 0-1
    activeListingRatio: number;   // 0-1
  }): number {
    const ratingScore =
      params.totalReviews > 0 ? (params.avgRating / 5) * 100 : 50;
    
    const responseScore =
      params.responseTimeAvg !== null
        ? Math.max(0, 100 - (params.responseTimeAvg / 3600) * 100) // 1hr → 0
        : 50;
    
    const conversionScore = params.conversionRate * 100;
    const listingScore = params.activeListingRatio * 100;

    const score =
      ratingScore * 0.4 +
      responseScore * 0.3 +
      conversionScore * 0.2 +
      listingScore * 0.1;

    return Math.round(score * 10) / 10; // 1 decimal place
  }
}
```

### Quality Score Update
**File:** `apps/api/src/modules/agents/application/commands/recalculate-quality-score/recalculate-quality-score.handler.ts`

**Domain Event:**
**File:** `apps/api/src/modules/agents/domain/events/quality-score-updated.event.ts`

```typescript
export class QualityScoreUpdatedEvent implements DomainEvent {
  readonly eventName = 'agent.quality_score_updated';
  readonly occurredAt = new Date();

  constructor(
    public readonly agentId: string,
    public readonly newScore: number,
    public readonly inputs: {
      avgRating: number;
      totalReviews: number;
      responseTimeAvg: number | null;
      conversionRate: number;
      activeListingRatio: number;
    },
  ) {}
}
```

**Data Flow:**
1. Review event triggers recalculation
2. Fetches agent's stats (reviews, response times, leads, listings)
3. Calls `QualityScoreCalculator.calculate()`
4. Persists score to `Agent.qualityScore` field
5. Publishes `QualityScoreUpdatedEvent`
6. Event may trigger agent ranking updates or search relevance changes

---

## 5. Inquiries Module

### Inquiry Tracking
**File:** `apps/api/src/modules/inquiries/application/commands/create-inquiry/create-inquiry.handler.ts`

```typescript
async execute(command: CreateInquiryCommand): Promise<CreateInquiryResult> {
  try {
    // 1. Validate listing exists
    const listing = await this.prisma.listing.findUnique({
      where: { id: command.listingId },
      select: { id: true },
    });
    if (!listing) {
      throw new NotFoundException('Listing', command.listingId);
    }

    // 2. Create inquiry entity
    const id = createId();
    const sanitizedMessage = this.sanitizer.sanitizeInquiryMessage(command.message);
    const inquiry = InquiryEntity.createNew(
      id,
      command.listingId,
      command.userId,
      sanitizedMessage,
      command.phone,
    );

    // 3. Save to repository
    await this.inquiryRepo.save(inquiry);

    // 4. Publish domain events (InquiryCreatedEvent)
    const events = inquiry.clearDomainEvents();
    for (const event of events) {
      this.eventBus.publish(event);
    }

    return {
      id,
      listingId: command.listingId,
      createdAt: inquiry.createdAt.toISOString(),
    };
  } catch (error) {
    // Error handling...
  }
}
```

### Inquiry Domain Event
**File:** `apps/api/src/modules/inquiries/domain/events/inquiry-created.event.ts`

```typescript
export class InquiryCreatedEvent implements DomainEvent {
  readonly eventName = 'inquiry.received';
  readonly occurredAt = new Date();

  constructor(
    public readonly aggregateId: string,      // inquiry ID
    public readonly listingId: string,
    public readonly userId: string,           // inquirer user ID
  ) {}
}
```

### inquiryCount in Listing
**File:** `apps/api/src/modules/listings/domain/entities/listing.entity.ts` (line 39, 61, 83, 104, 136)

```typescript
export interface ListingProps {
  // ...
  inquiryCount: number;  // ← Persisted here
  // ...
}

export class ListingEntity extends AggregateRoot<string> {
  private _inquiryCount: number;

  get inquiryCount(): number { return this._inquiryCount; }

  static createNew(...): ListingEntity {
    const listing = new ListingEntity(id, {
      // ...
      inquiryCount: 0,  // ← Initialized to 0
      // ...
    });
    return listing;
  }
}
```

### Inquiry DTO
**File:** `apps/api/src/modules/inquiries/domain/repositories/inquiry-read.dto.ts`

```typescript
export interface InquiryReadDto {
  id: string;
  listingId: string;
  listingTitle: string;
  userId: string;         // Inquirer's user ID
  userName: string;
  userPhone: string;      // Inquirer's phone
  message: string;        // Sanitized inquiry message
  phone: string | null;   // Alternate contact phone
  isRead: boolean;        // Seller/agent has read?
  createdAt: string;      // ISO 8601
}
```

**Important:** The system tracks inquiry count on the Listing entity. When `InquiryCreatedEvent` is published, a listener should increment `listing.inquiryCount` and persist it. (The current implementation may use eventual consistency via event handlers.)

---

## 6. Similar Listings (Comparables)

### Handler
**File:** `apps/api/src/modules/listings/application/queries/get-similar-listings/get-similar-listings.handler.ts`

```typescript
@QueryHandler(GetSimilarListingsQuery)
export class GetSimilarListingsHandler implements IQueryHandler<GetSimilarListingsQuery> {
  constructor(
    @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
  ) {}

  async execute(query: GetSimilarListingsQuery): Promise<ListingSimilarItem[]> {
    return this.listingRepo.findSimilar(query.listingId, query.limit);
  }
}
```

### Query Implementation
**File:** `apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts` (lines 296–369)

**Match Criteria:**
- Same `propertyType`
- Same `district`
- Price within **±10%** of source listing
- Area (m²) within **±20%** of source listing
- Status = `ACTIVE`
- Exclude source listing itself

**Sorting:** By price delta (ascending) — closest comparable first

```typescript
export async function findSimilarListingsQuery(
  prisma: PrismaService,
  id: string,
  limit: number,
): Promise<ListingSimilarItem[]> {
  // 1. Fetch source listing
  const source = await prisma.listing.findUnique({
    where: { id },
    select: {
      priceVND: true,
      property: {
        select: {
          propertyType: true,
          district: true,
          areaM2: true,
        },
      },
    },
  });

  if (!source) return [];

  // 2. Calculate price & area bounds
  const sourcePriceNum = Number(source.priceVND);
  const minPrice = BigInt(Math.floor(sourcePriceNum * 0.9));
  const maxPrice = BigInt(Math.ceil(sourcePriceNum * 1.1));
  const minArea = source.property.areaM2 * 0.8;
  const maxArea = source.property.areaM2 * 1.2;

  // 3. Query candidates
  const candidates = await prisma.listing.findMany({
    where: {
      id: { not: id },
      status: 'ACTIVE',
      priceVND: { gte: minPrice, lte: maxPrice },
      property: {
        propertyType: source.property.propertyType,
        district: source.property.district,
        areaM2: { gte: minArea, lte: maxArea },
      },
    },
    orderBy: { priceVND: 'asc' },
    take: limit * 3,  // Fetch 3x, then sort by delta
    include: {
      property: {
        include: { media: { orderBy: { order: 'asc' }, take: 1 } },
      },
    },
  });

  // 4. Sort by price delta & slice to limit
  return candidates
    .map((l) => ({ listing: l, delta: Math.abs(Number(l.priceVND) - sourcePriceNum) }))
    .sort((a, b) => a.delta - b.delta)
    .slice(0, limit)
    .map(({ listing }) => ({
      id: listing.id,
      title: listing.property.title,
      priceVND: listing.priceVND.toString(),
      areaM2: listing.property.areaM2,
      district: listing.property.district,
      thumbnailUrl: listing.property.media[0]?.url ?? null,
      publishedAt: listing.publishedAt?.toISOString() ?? null,
    }));
}
```

### Response DTO
**File:** `apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts` (lines 107–116)

```typescript
export interface ListingSimilarItem {
  id: string;
  title: string;
  priceVND: string;
  areaM2: number;
  district: string;
  thumbnailUrl: string | null;
  publishedAt: string | null;
}
```

### HTTP Endpoint
**File:** `apps/api/src/modules/listings/presentation/controllers/listings.controller.ts` (lines 223–234)

```typescript
@ApiOperation({ summary: 'Get similar listings (comparables) for a listing' })
@ApiParam({ name: 'id', description: 'Listing UUID' })
@ApiQuery({ name: 'limit', required: false, type: Number, example: 5, description: 'Max comparables to return (1–10, default 5)' })
@ApiResponse({ status: 200, description: 'Array of similar listings' })
@Get(':id/similar')
async getSimilarListings(
  @Param('id') id: string,
  @Query('limit') limit?: number,
): Promise<ListingSimilarItem[]> {
  const safeLimit = Math.min(Math.max(Number(limit) || 5, 1), 10);
  return this.queryBus.execute(new GetSimilarListingsQuery(id, safeLimit));
}
```

---

## 7. Caching Patterns (Redis)

### Cache Service
**File:** `apps/api/src/modules/shared/infrastructure/cache.service.ts`

**Key Characteristics:**
- Cache-aside pattern with Redis
- Graceful degradation when Redis is unavailable
- Metrics tracking: hit/miss/degradation counters
- Envelope-based storage with metadata (cachedAt, TTL)

### Cache Configuration

**TTLs:**
```typescript
export const CacheTTL = {
  LISTING_DETAIL: 300,        // 5 min
  SEARCH_RESULTS: 120,        // 2 min
  DISTRICT_STATS: 300,        // 5 min
  MARKET_REPORT: 900,         // 15 min
  HEATMAP: 300,               // 5 min
  MARKET_DATA: 1800,          // 30 min
  USER_PROFILE: 600,          // 10 min
  USER_QUOTA: 60,             // 1 min
  PLAN_LIST: 3600,            // 1 hour
  REFERENCE_DATA: 86400,      // 24 hours
  MARKET_SNAPSHOT: 300,       // 5 min
  TRENDING_AREAS: 1800,       // 30 min
};
```

**Cache Key Prefixes:**
```typescript
export enum CachePrefix {
  LISTING = 'cache:listing',
  SEARCH = 'cache:search',
  GEO_SEARCH = 'cache:geo_search',
  MARKET_REPORT = 'cache:market:report',
  MARKET_TREND = 'cache:market:trend',
  MARKET_HEATMAP = 'cache:market:heatmap',
  MARKET_DISTRICT = 'cache:market:district',
  USER_PROFILE = 'cache:user:profile',
  USER_QUOTA = 'cache:user:quota',
  VALUATION = 'cache:valuation',
  PLAN_LIST = 'cache:plan:list',
  REFERENCE = 'cache:reference',
  AGENT_LISTINGS = 'cache:agent:listings',
  MARKET_SNAPSHOT = 'cache:analytics:market_snapshot',
  TRENDING_AREAS = 'cache:analytics:trending_areas',
}
```

### getOrSet Method
```typescript
async getOrSet<T>(
  key: string,
  loader: () => Promise<T>,
  ttlSeconds: number,
  resource: string,
): Promise<T> {
  const store = cacheMetaStorage.getStore();

  // Fast-path: skip Redis if unavailable
  if (!this.redis.isAvailable()) {
    this.cacheDegradationCounter.inc({ resource, operation: 'skip_unavailable' });
    this.cacheMissCounter.inc({ resource });
    if (store) {
      store.meta = { cachedAt: null, nextRefreshAt: null, source: 'fresh' };
    }
    return loader();
  }

  try {
    const cached = await this.redis.get(key);
    if (cached !== null) {
      this.cacheHitCounter.inc({ resource });
      const parsed = JSON.parse(cached) as unknown;
      
      // Check for envelope format (written by this service)
      if (
        parsed !== null &&
        typeof parsed === 'object' &&
        '__v' in (parsed as object) &&
        'cachedAt' in (parsed as object)
      ) {
        const envelope = parsed as { __v: T; cachedAt: string; ttlSeconds: number };
        if (store) {
          const nextRefreshAt = new Date(
            new Date(envelope.cachedAt).getTime() + envelope.ttlSeconds * 1000,
          ).toISOString();
          store.meta = { cachedAt: envelope.cachedAt, nextRefreshAt, source: 'cache' };
        }
        return envelope.__v;
      }
      
      // Legacy plain value
      if (store) {
        store.meta = { cachedAt: null, nextRefreshAt: null, source: 'cache' };
      }
      return parsed as T;
    }
  } catch (err) {
    this.cacheDegradationCounter.inc({ resource, operation: 'read_error' });
    this.logger.warn(`Cache read error for ${key}: ${(err as Error).message}`, 'CacheService');
  }

  // Cache miss: call loader
  this.cacheMissCounter.inc({ resource });
  const result = await loader();

  const cachedAt = new Date().toISOString();
  if (store) {
    const nextRefreshAt = new Date(new Date(cachedAt).getTime() + ttlSeconds * 1000).toISOString();
    store.meta = { cachedAt, nextRefreshAt, source: 'fresh' };
  }

  // Write to cache (with error handling)
  try {
    const envelope = { __v: result, cachedAt, ttlSeconds };
    await this.redis.set(key, JSON.stringify(envelope), ttlSeconds);
  } catch (err) {
    this.cacheDegradationCounter.inc({ resource, operation: 'write_error' });
    this.logger.warn(`Cache write error for ${key}: ${(err as Error).message}`, 'CacheService');
  }

  return result;
}
```

### Invalidation Methods
```typescript
// Invalidate a single key
async invalidate(key: string): Promise<void>

// Invalidate all keys matching a prefix (uses SCAN)
async invalidateByPrefix(prefix: string): Promise<void>

// Build cache key deterministically
static buildKey(prefix: CachePrefix, ...parts: (string | number | undefined)[]): string
```

### Metrics
- `cache_hit_total` — Cache hit counter (by resource)
- `cache_miss_total` — Cache miss counter (by resource)
- `cache_degradation_total` — Degradation counter (by resource & operation)

### Usage in GetListingHandler
```typescript
const cacheKey = CacheService.buildKey(CachePrefix.LISTING, query.listingId);

const cached = await this.cache.getOrSet<ListingDetailData | null>(
  cacheKey,
  async () => {
    const result = await this.listingRepo.findByIdWithProperty(query.listingId);
    if (!result) {
      throw new ListingNotFoundSignal();  // Signal: don't cache null
    }
    return result;
  },
  CacheTTL.LISTING_DETAIL,
  'listing',
);
```

---

## Summary Table

| Component | File Path | Key Details |
|-----------|-----------|-------------|
| **GET /listings/:id Handler** | `apps/api/src/modules/listings/presentation/controllers/listings.controller.ts` | Line 236–247; uses QueryBus to GetListingQuery |
| **GetListingHandler** | `apps/api/src/modules/listings/application/queries/get-listing/get-listing.handler.ts` | Cache-aside (Redis), TTL 300s, not-found signal avoids null cache |
| **ListingDetailData DTO** | `apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts` | Full listing with property, seller, agent; includes inquiryCount, viewCount, saveCount |
| **AVM Service Interface** | `apps/api/src/modules/analytics/domain/services/avm-service.ts` | IAVMService, AVMParams, ValuationResult |
| **HttpAVMService** | `apps/api/src/modules/analytics/infrastructure/services/http-avm.service.ts` | Calls Python AI service; fallback to PrismaAVMService; batch concurrency 5 |
| **AI Service Client** | `apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts` | AiPredictRequest (v1), AiPredictV2Request (extended); HTTP client wrapper |
| **Agent Quality Score** | `apps/api/src/modules/agents/domain/services/quality-score.service.ts` | QualityScoreCalculator: 40% reviews + 30% response time + 20% conversion + 10% listing activity |
| **Agent Profile Queries** | `apps/api/src/modules/agents/infrastructure/repositories/agent-profile.queries.ts` | buildPublicProfile() — fetches qualityScore from agent record |
| **Inquiry Handler** | `apps/api/src/modules/inquiries/application/commands/create-inquiry/create-inquiry.handler.ts` | Creates inquiry, publishes InquiryCreatedEvent |
| **Inquiry DTO** | `apps/api/src/modules/inquiries/domain/repositories/inquiry-read.dto.ts` | InquiryReadDto with listingId, userId, message, isRead, createdAt |
| **Similar Listings Query** | `apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts` | findSimilarListingsQuery(): same propertyType/district, price ±10%, area ±20%, sorted by price delta |
| **Cache Service** | `apps/api/src/modules/shared/infrastructure/cache.service.ts` | Cache-aside with envelope metadata; graceful Redis degradation; LISTING_DETAIL TTL 300s |

---

## Key Insights

1. **GET /listings/:id** uses **cache-aside** with a 5-minute TTL and avoids caching null results to allow newly-created listings to be discoverable.

2. **inquiryCount** is a **denormalized counter** on the Listing entity, likely updated via event handlers when InquiryCreatedEvent is published.

3. **AVM Service** is **dual-architecture**: primary (AI service v1/v2 via HTTP) + fallback (comparables via PostGIS). Batch operations are rate-limited to 5 concurrent requests.

4. **Agent Quality Score** is a **weighted aggregate** (40% reviews, 30% response time, 20% conversion, 10% listing activity), recalculated from review and inquiry events.

5. **Similar Listings** use a **simple rule-based matcher** (price ±10%, area ±20%, same property type & district), not ML-based similarity.

6. **Redis Caching** includes **metrics instrumentation** and **graceful degradation** — the system works even if Redis is down, with telemetry to alert ops.

7. **Cache Metadata** is **envelope-based** for frontend consumption (cachedAt, nextRefreshAt, source), supporting transparent legacy value handling.

---

## Next Steps (For Implementation)

1. Verify how `inquiryCount` is incremented when `InquiryCreatedEvent` is published (event listener in listings module).
2. Check if search results or listings by seller ID are also cached with similar patterns.
3. Review how featured listings update the cache invalidation strategy.
4. Verify AVM service integration tests to understand error handling in detail.
5. Check agent quality score recalculation triggers (review events, inquiry conversions).

