Files
goodgo-platform/docs/explorations/LISTINGS_MODULE_EXPLORATION.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

31 KiB
Raw Permalink Blame History

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)

@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
@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

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;
}
  • 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

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
@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):

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:

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)

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):

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

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

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

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)

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

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

@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 296369)

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

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 107116)

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 223234)

@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 (110, 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:

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:

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

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

// 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

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 236247; 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).