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:
965
docs/explorations/LISTINGS_MODULE_EXPLORATION.md
Normal file
965
docs/explorations/LISTINGS_MODULE_EXPLORATION.md
Normal 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 296–369)
|
||||
|
||||
**Match Criteria:**
|
||||
- Same `propertyType`
|
||||
- Same `district`
|
||||
- Price within **±10%** of source listing
|
||||
- Area (m²) within **±20%** of source listing
|
||||
- Status = `ACTIVE`
|
||||
- Exclude source listing itself
|
||||
|
||||
**Sorting:** By price delta (ascending) — closest comparable first
|
||||
|
||||
```typescript
|
||||
export async function findSimilarListingsQuery(
|
||||
prisma: PrismaService,
|
||||
id: string,
|
||||
limit: number,
|
||||
): Promise<ListingSimilarItem[]> {
|
||||
// 1. Fetch source listing
|
||||
const source = await prisma.listing.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
priceVND: true,
|
||||
property: {
|
||||
select: {
|
||||
propertyType: true,
|
||||
district: true,
|
||||
areaM2: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!source) return [];
|
||||
|
||||
// 2. Calculate price & area bounds
|
||||
const sourcePriceNum = Number(source.priceVND);
|
||||
const minPrice = BigInt(Math.floor(sourcePriceNum * 0.9));
|
||||
const maxPrice = BigInt(Math.ceil(sourcePriceNum * 1.1));
|
||||
const minArea = source.property.areaM2 * 0.8;
|
||||
const maxArea = source.property.areaM2 * 1.2;
|
||||
|
||||
// 3. Query candidates
|
||||
const candidates = await prisma.listing.findMany({
|
||||
where: {
|
||||
id: { not: id },
|
||||
status: 'ACTIVE',
|
||||
priceVND: { gte: minPrice, lte: maxPrice },
|
||||
property: {
|
||||
propertyType: source.property.propertyType,
|
||||
district: source.property.district,
|
||||
areaM2: { gte: minArea, lte: maxArea },
|
||||
},
|
||||
},
|
||||
orderBy: { priceVND: 'asc' },
|
||||
take: limit * 3, // Fetch 3x, then sort by delta
|
||||
include: {
|
||||
property: {
|
||||
include: { media: { orderBy: { order: 'asc' }, take: 1 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Sort by price delta & slice to limit
|
||||
return candidates
|
||||
.map((l) => ({ listing: l, delta: Math.abs(Number(l.priceVND) - sourcePriceNum) }))
|
||||
.sort((a, b) => a.delta - b.delta)
|
||||
.slice(0, limit)
|
||||
.map(({ listing }) => ({
|
||||
id: listing.id,
|
||||
title: listing.property.title,
|
||||
priceVND: listing.priceVND.toString(),
|
||||
areaM2: listing.property.areaM2,
|
||||
district: listing.property.district,
|
||||
thumbnailUrl: listing.property.media[0]?.url ?? null,
|
||||
publishedAt: listing.publishedAt?.toISOString() ?? null,
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
### Response DTO
|
||||
**File:** `apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts` (lines 107–116)
|
||||
|
||||
```typescript
|
||||
export interface ListingSimilarItem {
|
||||
id: string;
|
||||
title: string;
|
||||
priceVND: string;
|
||||
areaM2: number;
|
||||
district: string;
|
||||
thumbnailUrl: string | null;
|
||||
publishedAt: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
### HTTP Endpoint
|
||||
**File:** `apps/api/src/modules/listings/presentation/controllers/listings.controller.ts` (lines 223–234)
|
||||
|
||||
```typescript
|
||||
@ApiOperation({ summary: 'Get similar listings (comparables) for a listing' })
|
||||
@ApiParam({ name: 'id', description: 'Listing UUID' })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, example: 5, description: 'Max comparables to return (1–10, default 5)' })
|
||||
@ApiResponse({ status: 200, description: 'Array of similar listings' })
|
||||
@Get(':id/similar')
|
||||
async getSimilarListings(
|
||||
@Param('id') id: string,
|
||||
@Query('limit') limit?: number,
|
||||
): Promise<ListingSimilarItem[]> {
|
||||
const safeLimit = Math.min(Math.max(Number(limit) || 5, 1), 10);
|
||||
return this.queryBus.execute(new GetSimilarListingsQuery(id, safeLimit));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Caching Patterns (Redis)
|
||||
|
||||
### Cache Service
|
||||
**File:** `apps/api/src/modules/shared/infrastructure/cache.service.ts`
|
||||
|
||||
**Key Characteristics:**
|
||||
- Cache-aside pattern with Redis
|
||||
- Graceful degradation when Redis is unavailable
|
||||
- Metrics tracking: hit/miss/degradation counters
|
||||
- Envelope-based storage with metadata (cachedAt, TTL)
|
||||
|
||||
### Cache Configuration
|
||||
|
||||
**TTLs:**
|
||||
```typescript
|
||||
export const CacheTTL = {
|
||||
LISTING_DETAIL: 300, // 5 min
|
||||
SEARCH_RESULTS: 120, // 2 min
|
||||
DISTRICT_STATS: 300, // 5 min
|
||||
MARKET_REPORT: 900, // 15 min
|
||||
HEATMAP: 300, // 5 min
|
||||
MARKET_DATA: 1800, // 30 min
|
||||
USER_PROFILE: 600, // 10 min
|
||||
USER_QUOTA: 60, // 1 min
|
||||
PLAN_LIST: 3600, // 1 hour
|
||||
REFERENCE_DATA: 86400, // 24 hours
|
||||
MARKET_SNAPSHOT: 300, // 5 min
|
||||
TRENDING_AREAS: 1800, // 30 min
|
||||
};
|
||||
```
|
||||
|
||||
**Cache Key Prefixes:**
|
||||
```typescript
|
||||
export enum CachePrefix {
|
||||
LISTING = 'cache:listing',
|
||||
SEARCH = 'cache:search',
|
||||
GEO_SEARCH = 'cache:geo_search',
|
||||
MARKET_REPORT = 'cache:market:report',
|
||||
MARKET_TREND = 'cache:market:trend',
|
||||
MARKET_HEATMAP = 'cache:market:heatmap',
|
||||
MARKET_DISTRICT = 'cache:market:district',
|
||||
USER_PROFILE = 'cache:user:profile',
|
||||
USER_QUOTA = 'cache:user:quota',
|
||||
VALUATION = 'cache:valuation',
|
||||
PLAN_LIST = 'cache:plan:list',
|
||||
REFERENCE = 'cache:reference',
|
||||
AGENT_LISTINGS = 'cache:agent:listings',
|
||||
MARKET_SNAPSHOT = 'cache:analytics:market_snapshot',
|
||||
TRENDING_AREAS = 'cache:analytics:trending_areas',
|
||||
}
|
||||
```
|
||||
|
||||
### getOrSet Method
|
||||
```typescript
|
||||
async getOrSet<T>(
|
||||
key: string,
|
||||
loader: () => Promise<T>,
|
||||
ttlSeconds: number,
|
||||
resource: string,
|
||||
): Promise<T> {
|
||||
const store = cacheMetaStorage.getStore();
|
||||
|
||||
// Fast-path: skip Redis if unavailable
|
||||
if (!this.redis.isAvailable()) {
|
||||
this.cacheDegradationCounter.inc({ resource, operation: 'skip_unavailable' });
|
||||
this.cacheMissCounter.inc({ resource });
|
||||
if (store) {
|
||||
store.meta = { cachedAt: null, nextRefreshAt: null, source: 'fresh' };
|
||||
}
|
||||
return loader();
|
||||
}
|
||||
|
||||
try {
|
||||
const cached = await this.redis.get(key);
|
||||
if (cached !== null) {
|
||||
this.cacheHitCounter.inc({ resource });
|
||||
const parsed = JSON.parse(cached) as unknown;
|
||||
|
||||
// Check for envelope format (written by this service)
|
||||
if (
|
||||
parsed !== null &&
|
||||
typeof parsed === 'object' &&
|
||||
'__v' in (parsed as object) &&
|
||||
'cachedAt' in (parsed as object)
|
||||
) {
|
||||
const envelope = parsed as { __v: T; cachedAt: string; ttlSeconds: number };
|
||||
if (store) {
|
||||
const nextRefreshAt = new Date(
|
||||
new Date(envelope.cachedAt).getTime() + envelope.ttlSeconds * 1000,
|
||||
).toISOString();
|
||||
store.meta = { cachedAt: envelope.cachedAt, nextRefreshAt, source: 'cache' };
|
||||
}
|
||||
return envelope.__v;
|
||||
}
|
||||
|
||||
// Legacy plain value
|
||||
if (store) {
|
||||
store.meta = { cachedAt: null, nextRefreshAt: null, source: 'cache' };
|
||||
}
|
||||
return parsed as T;
|
||||
}
|
||||
} catch (err) {
|
||||
this.cacheDegradationCounter.inc({ resource, operation: 'read_error' });
|
||||
this.logger.warn(`Cache read error for ${key}: ${(err as Error).message}`, 'CacheService');
|
||||
}
|
||||
|
||||
// Cache miss: call loader
|
||||
this.cacheMissCounter.inc({ resource });
|
||||
const result = await loader();
|
||||
|
||||
const cachedAt = new Date().toISOString();
|
||||
if (store) {
|
||||
const nextRefreshAt = new Date(new Date(cachedAt).getTime() + ttlSeconds * 1000).toISOString();
|
||||
store.meta = { cachedAt, nextRefreshAt, source: 'fresh' };
|
||||
}
|
||||
|
||||
// Write to cache (with error handling)
|
||||
try {
|
||||
const envelope = { __v: result, cachedAt, ttlSeconds };
|
||||
await this.redis.set(key, JSON.stringify(envelope), ttlSeconds);
|
||||
} catch (err) {
|
||||
this.cacheDegradationCounter.inc({ resource, operation: 'write_error' });
|
||||
this.logger.warn(`Cache write error for ${key}: ${(err as Error).message}`, 'CacheService');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
### Invalidation Methods
|
||||
```typescript
|
||||
// Invalidate a single key
|
||||
async invalidate(key: string): Promise<void>
|
||||
|
||||
// Invalidate all keys matching a prefix (uses SCAN)
|
||||
async invalidateByPrefix(prefix: string): Promise<void>
|
||||
|
||||
// Build cache key deterministically
|
||||
static buildKey(prefix: CachePrefix, ...parts: (string | number | undefined)[]): string
|
||||
```
|
||||
|
||||
### Metrics
|
||||
- `cache_hit_total` — Cache hit counter (by resource)
|
||||
- `cache_miss_total` — Cache miss counter (by resource)
|
||||
- `cache_degradation_total` — Degradation counter (by resource & operation)
|
||||
|
||||
### Usage in GetListingHandler
|
||||
```typescript
|
||||
const cacheKey = CacheService.buildKey(CachePrefix.LISTING, query.listingId);
|
||||
|
||||
const cached = await this.cache.getOrSet<ListingDetailData | null>(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const result = await this.listingRepo.findByIdWithProperty(query.listingId);
|
||||
if (!result) {
|
||||
throw new ListingNotFoundSignal(); // Signal: don't cache null
|
||||
}
|
||||
return result;
|
||||
},
|
||||
CacheTTL.LISTING_DETAIL,
|
||||
'listing',
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary Table
|
||||
|
||||
| Component | File Path | Key Details |
|
||||
|-----------|-----------|-------------|
|
||||
| **GET /listings/:id Handler** | `apps/api/src/modules/listings/presentation/controllers/listings.controller.ts` | Line 236–247; uses QueryBus to GetListingQuery |
|
||||
| **GetListingHandler** | `apps/api/src/modules/listings/application/queries/get-listing/get-listing.handler.ts` | Cache-aside (Redis), TTL 300s, not-found signal avoids null cache |
|
||||
| **ListingDetailData DTO** | `apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts` | Full listing with property, seller, agent; includes inquiryCount, viewCount, saveCount |
|
||||
| **AVM Service Interface** | `apps/api/src/modules/analytics/domain/services/avm-service.ts` | IAVMService, AVMParams, ValuationResult |
|
||||
| **HttpAVMService** | `apps/api/src/modules/analytics/infrastructure/services/http-avm.service.ts` | Calls Python AI service; fallback to PrismaAVMService; batch concurrency 5 |
|
||||
| **AI Service Client** | `apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts` | AiPredictRequest (v1), AiPredictV2Request (extended); HTTP client wrapper |
|
||||
| **Agent Quality Score** | `apps/api/src/modules/agents/domain/services/quality-score.service.ts` | QualityScoreCalculator: 40% reviews + 30% response time + 20% conversion + 10% listing activity |
|
||||
| **Agent Profile Queries** | `apps/api/src/modules/agents/infrastructure/repositories/agent-profile.queries.ts` | buildPublicProfile() — fetches qualityScore from agent record |
|
||||
| **Inquiry Handler** | `apps/api/src/modules/inquiries/application/commands/create-inquiry/create-inquiry.handler.ts` | Creates inquiry, publishes InquiryCreatedEvent |
|
||||
| **Inquiry DTO** | `apps/api/src/modules/inquiries/domain/repositories/inquiry-read.dto.ts` | InquiryReadDto with listingId, userId, message, isRead, createdAt |
|
||||
| **Similar Listings Query** | `apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts` | findSimilarListingsQuery(): same propertyType/district, price ±10%, area ±20%, sorted by price delta |
|
||||
| **Cache Service** | `apps/api/src/modules/shared/infrastructure/cache.service.ts` | Cache-aside with envelope metadata; graceful Redis degradation; LISTING_DETAIL TTL 300s |
|
||||
|
||||
---
|
||||
|
||||
## Key Insights
|
||||
|
||||
1. **GET /listings/:id** uses **cache-aside** with a 5-minute TTL and avoids caching null results to allow newly-created listings to be discoverable.
|
||||
|
||||
2. **inquiryCount** is a **denormalized counter** on the Listing entity, likely updated via event handlers when InquiryCreatedEvent is published.
|
||||
|
||||
3. **AVM Service** is **dual-architecture**: primary (AI service v1/v2 via HTTP) + fallback (comparables via PostGIS). Batch operations are rate-limited to 5 concurrent requests.
|
||||
|
||||
4. **Agent Quality Score** is a **weighted aggregate** (40% reviews, 30% response time, 20% conversion, 10% listing activity), recalculated from review and inquiry events.
|
||||
|
||||
5. **Similar Listings** use a **simple rule-based matcher** (price ±10%, area ±20%, same property type & district), not ML-based similarity.
|
||||
|
||||
6. **Redis Caching** includes **metrics instrumentation** and **graceful degradation** — the system works even if Redis is down, with telemetry to alert ops.
|
||||
|
||||
7. **Cache Metadata** is **envelope-based** for frontend consumption (cachedAt, nextRefreshAt, source), supporting transparent legacy value handling.
|
||||
|
||||
---
|
||||
|
||||
## Next Steps (For Implementation)
|
||||
|
||||
1. Verify how `inquiryCount` is incremented when `InquiryCreatedEvent` is published (event listener in listings module).
|
||||
2. Check if search results or listings by seller ID are also cached with similar patterns.
|
||||
3. Review how featured listings update the cache invalidation strategy.
|
||||
4. Verify AVM service integration tests to understand error handling in detail.
|
||||
5. Check agent quality score recalculation triggers (review events, inquiry conversions).
|
||||
|
||||
Reference in New Issue
Block a user