# 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 { 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` - 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 { constructor( @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository, private readonly cache: CacheService, private readonly logger: LoggerService, ) {} async execute(query: GetListingQuery): Promise { try { const cacheKey = CacheService.buildKey(CachePrefix.LISTING, query.listingId); const cached = await this.cache.getOrSet( 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 { 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 { return this.fallback.getComparables(propertyId, radiusMeters); } async estimateBatch(items: BatchValuationItem[]): Promise { // Processes in chunks with max concurrency 5 } } ``` **estimateViaAi Method (Simplified):** ```typescript private async estimateViaAi(params: AVMParams): Promise { // 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 { 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 { 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 { 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 { constructor( @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository, ) {} async execute(query: GetSimilarListingsQuery): Promise { 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 { // 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 { 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( key: string, loader: () => Promise, ttlSeconds: number, resource: string, ): Promise { 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 // Invalidate all keys matching a prefix (uses SCAN) async invalidateByPrefix(prefix: string): Promise // 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( 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).