- 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>
31 KiB
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
ListingNotFoundSignalto 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;
}
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
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:
- Review event triggers recalculation
- Fetches agent's stats (reviews, response times, leads, listings)
- Calls
QualityScoreCalculator.calculate() - Persists score to
Agent.qualityScorefield - Publishes
QualityScoreUpdatedEvent - 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 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
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)
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)
@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:
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 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
-
GET /listings/:id uses cache-aside with a 5-minute TTL and avoids caching null results to allow newly-created listings to be discoverable.
-
inquiryCount is a denormalized counter on the Listing entity, likely updated via event handlers when InquiryCreatedEvent is published.
-
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.
-
Agent Quality Score is a weighted aggregate (40% reviews, 30% response time, 20% conversion, 10% listing activity), recalculated from review and inquiry events.
-
Similar Listings use a simple rule-based matcher (price ±10%, area ±20%, same property type & district), not ML-based similarity.
-
Redis Caching includes metrics instrumentation and graceful degradation — the system works even if Redis is down, with telemetry to alert ops.
-
Cache Metadata is envelope-based for frontend consumption (cachedAt, nextRefreshAt, source), supporting transparent legacy value handling.
Next Steps (For Implementation)
- Verify how
inquiryCountis incremented whenInquiryCreatedEventis published (event listener in listings module). - Check if search results or listings by seller ID are also cached with similar patterns.
- Review how featured listings update the cache invalidation strategy.
- Verify AVM service integration tests to understand error handling in detail.
- Check agent quality score recalculation triggers (review events, inquiry conversions).