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>
This commit is contained in:
Ho Ngoc Hai
2026-04-21 16:29:24 +07:00
parent 912121cf09
commit 08b96f9c2d
39 changed files with 15129 additions and 562 deletions

View File

@@ -0,0 +1,965 @@
# 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 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
```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 107116)
```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 223234)
```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 (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:**
```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 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).