feat(listings): enrich GET /listings/:id with AVM, agent quality score, and similar count

- ListingDetailData: add valuationEstimate (AVM, cached 24 h), agentQualityScore
  (denormalised tier from Agent.qualityScore), similarCount, and gate inquiryCount
  (null for public callers; visible to listing owner or ADMIN)
- listing-read.queries: select agent.qualityScore, derive tier, count similar listings
  in the same query via prisma.listing.count
- GetListingQuery: add optional CallerContext (userId, role) for access control
- GetListingHandler: inject AVM_SERVICE, fire AVM estimation with 24 h valuation cache,
  gracefully degrade to null on AVM failure, redact inquiryCount for non-privileged callers
- OptionalJwtAuthGuard: new guard that sets request.user without throwing for anonymous
  requests; used on GET :id so the controller can pass caller identity to the query
- ListingsModule: import AnalyticsModule so AVM_SERVICE is available for injection
- CacheTTL: add VALUATION_LISTING (86400 s / 24 h)
- Tests: 14 unit tests + 3 snapshot tests (public / owner / admin roles), all passing

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-21 02:43:56 +07:00
parent f7b0fe6f5d
commit 805aaeffad
12 changed files with 727 additions and 43 deletions

View File

@@ -1,3 +1,5 @@
export { AnalyticsModule } from './analytics.module';
export { MARKET_INDEX_REPOSITORY, IMarketIndexRepository } from './domain/repositories/market-index.repository';
export { VALUATION_REPOSITORY, IValuationRepository } from './domain/repositories/valuation.repository';
export { AVM_SERVICE } from './domain/services/avm-service';
export type { IAVMService, AVMParams, ValuationResult } from './domain/services/avm-service';

View File

@@ -1,5 +1,6 @@
export { AuthModule } from './auth.module';
export { JwtAuthGuard } from './presentation/guards/jwt-auth.guard';
export { OptionalJwtAuthGuard } from './presentation/guards/optional-jwt-auth.guard';
export { RolesGuard } from './presentation/guards/roles.guard';
export { Roles } from './presentation/decorators/roles.decorator';
export { CurrentUser } from './presentation/decorators/current-user.decorator';

View File

@@ -0,0 +1,21 @@
import { Injectable, type ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
/**
* JWT guard that does NOT throw when the token is absent or invalid.
* When no valid token is provided, `request.user` is left as `undefined`.
* Use this for endpoints that are public but can serve richer data to
* authenticated callers (e.g. listing detail with access-gated fields).
*/
@Injectable()
export class OptionalJwtAuthGuard extends AuthGuard('jwt') {
override canActivate(context: ExecutionContext) {
return super.canActivate(context);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
override handleRequest<TUser = any>(_err: unknown, user: TUser): TUser {
// Return whatever passport resolved (may be false/undefined for anonymous requests)
return user;
}
}

View File

@@ -0,0 +1,265 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`GET /listings/:id — enriched response snapshot > admin caller: inquiryCount is visible 1`] = `
{
"agent": {
"agency": "Đất Xanh Group",
"id": "agent-snap-1",
"userId": "user-agent",
},
"agentQualityScore": {
"score": 90,
"tier": "PLATINUM",
},
"commissionPct": 2.5,
"createdAt": "2026-03-20T00:00:00.000Z",
"featuredUntil": "2026-06-01T00:00:00.000Z",
"id": "listing-snap-1",
"inquiryCount": 12,
"isFeatured": true,
"pricePerM2": 93750000,
"priceVND": "7500000000",
"property": {
"address": "1 Nguyễn Văn Linh",
"amenities": null,
"areaM2": 80,
"balconyDirection": "EAST",
"bathrooms": 2,
"bedrooms": 2,
"city": "Hồ Chí Minh",
"description": "Căn hộ cao cấp",
"direction": "SOUTH",
"district": "Quận 7",
"floor": 15,
"floors": null,
"furnishing": "FULL",
"id": "prop-snap-1",
"latitude": 10.725,
"legalStatus": "Sổ hồng",
"longitude": 106.7,
"maintenanceFeeVND": "3000000",
"media": [
{
"caption": null,
"id": "m-1",
"order": 0,
"type": "image",
"url": "https://cdn.example.com/1.jpg",
},
],
"metroDistanceM": 500,
"nearbyPOIs": null,
"parkingSlots": 1,
"petFriendly": true,
"projectName": "The River",
"propertyCondition": "NEW",
"propertyType": "APARTMENT",
"suitableFor": [
"FAMILY",
],
"title": "Căn hộ view sông Q4",
"totalFloors": 30,
"usableAreaM2": 75,
"viewType": [
"RIVER",
],
"ward": "Tân Phong",
"whyThisLocation": "Vị trí đắc địa",
"yearBuilt": 2022,
},
"publishedAt": "2026-04-01T00:00:00.000Z",
"rentPriceMonthly": null,
"saveCount": 7,
"seller": {
"fullName": "Trần Thị B",
"id": "seller-snap-1",
"phone": "0911234567",
},
"similarCount": 8,
"status": "ACTIVE",
"transactionType": "SALE",
"valuationEstimate": {
"confidence": 0.91,
"estimatedAt": "2026-04-21T00:00:00.000Z",
"modelVersion": "v2.1",
"value": "7800000000",
},
"viewCount": 42,
}
`;
exports[`GET /listings/:id — enriched response snapshot > owner caller: inquiryCount is visible 1`] = `
{
"agent": {
"agency": "Đất Xanh Group",
"id": "agent-snap-1",
"userId": "user-agent",
},
"agentQualityScore": {
"score": 90,
"tier": "PLATINUM",
},
"commissionPct": 2.5,
"createdAt": "2026-03-20T00:00:00.000Z",
"featuredUntil": "2026-06-01T00:00:00.000Z",
"id": "listing-snap-1",
"inquiryCount": 12,
"isFeatured": true,
"pricePerM2": 93750000,
"priceVND": "7500000000",
"property": {
"address": "1 Nguyễn Văn Linh",
"amenities": null,
"areaM2": 80,
"balconyDirection": "EAST",
"bathrooms": 2,
"bedrooms": 2,
"city": "Hồ Chí Minh",
"description": "Căn hộ cao cấp",
"direction": "SOUTH",
"district": "Quận 7",
"floor": 15,
"floors": null,
"furnishing": "FULL",
"id": "prop-snap-1",
"latitude": 10.725,
"legalStatus": "Sổ hồng",
"longitude": 106.7,
"maintenanceFeeVND": "3000000",
"media": [
{
"caption": null,
"id": "m-1",
"order": 0,
"type": "image",
"url": "https://cdn.example.com/1.jpg",
},
],
"metroDistanceM": 500,
"nearbyPOIs": null,
"parkingSlots": 1,
"petFriendly": true,
"projectName": "The River",
"propertyCondition": "NEW",
"propertyType": "APARTMENT",
"suitableFor": [
"FAMILY",
],
"title": "Căn hộ view sông Q4",
"totalFloors": 30,
"usableAreaM2": 75,
"viewType": [
"RIVER",
],
"ward": "Tân Phong",
"whyThisLocation": "Vị trí đắc địa",
"yearBuilt": 2022,
},
"publishedAt": "2026-04-01T00:00:00.000Z",
"rentPriceMonthly": null,
"saveCount": 7,
"seller": {
"fullName": "Trần Thị B",
"id": "seller-snap-1",
"phone": "0911234567",
},
"similarCount": 8,
"status": "ACTIVE",
"transactionType": "SALE",
"valuationEstimate": {
"confidence": 0.91,
"estimatedAt": "2026-04-21T00:00:00.000Z",
"modelVersion": "v2.1",
"value": "7800000000",
},
"viewCount": 42,
}
`;
exports[`GET /listings/:id — enriched response snapshot > public caller: inquiryCount is null, all other enrichment fields present 1`] = `
{
"agent": {
"agency": "Đất Xanh Group",
"id": "agent-snap-1",
"userId": "user-agent",
},
"agentQualityScore": {
"score": 90,
"tier": "PLATINUM",
},
"commissionPct": 2.5,
"createdAt": "2026-03-20T00:00:00.000Z",
"featuredUntil": "2026-06-01T00:00:00.000Z",
"id": "listing-snap-1",
"inquiryCount": null,
"isFeatured": true,
"pricePerM2": 93750000,
"priceVND": "7500000000",
"property": {
"address": "1 Nguyễn Văn Linh",
"amenities": null,
"areaM2": 80,
"balconyDirection": "EAST",
"bathrooms": 2,
"bedrooms": 2,
"city": "Hồ Chí Minh",
"description": "Căn hộ cao cấp",
"direction": "SOUTH",
"district": "Quận 7",
"floor": 15,
"floors": null,
"furnishing": "FULL",
"id": "prop-snap-1",
"latitude": 10.725,
"legalStatus": "Sổ hồng",
"longitude": 106.7,
"maintenanceFeeVND": "3000000",
"media": [
{
"caption": null,
"id": "m-1",
"order": 0,
"type": "image",
"url": "https://cdn.example.com/1.jpg",
},
],
"metroDistanceM": 500,
"nearbyPOIs": null,
"parkingSlots": 1,
"petFriendly": true,
"projectName": "The River",
"propertyCondition": "NEW",
"propertyType": "APARTMENT",
"suitableFor": [
"FAMILY",
],
"title": "Căn hộ view sông Q4",
"totalFloors": 30,
"usableAreaM2": 75,
"viewType": [
"RIVER",
],
"ward": "Tân Phong",
"whyThisLocation": "Vị trí đắc địa",
"yearBuilt": 2022,
},
"publishedAt": "2026-04-01T00:00:00.000Z",
"rentPriceMonthly": null,
"saveCount": 7,
"seller": {
"fullName": "Trần Thị B",
"id": "seller-snap-1",
"phone": "0911234567",
},
"similarCount": 8,
"status": "ACTIVE",
"transactionType": "SALE",
"valuationEstimate": {
"confidence": 0.91,
"estimatedAt": "2026-04-21T00:00:00.000Z",
"modelVersion": "v2.1",
"value": "7800000000",
},
"viewCount": 42,
}
`;

View File

@@ -0,0 +1,135 @@
/**
* Snapshot tests for GET /listings/:id enriched response.
*
* Three roles are tested:
* - public (no caller) → inquiryCount must be null
* - owner (seller-1) → inquiryCount visible
* - admin (ADMIN) → inquiryCount visible
*/
import { GetListingHandler } from '../queries/get-listing/get-listing.handler';
import { GetListingQuery } from '../queries/get-listing/get-listing.query';
const FROZEN_DATE = '2026-04-21T00:00:00.000Z';
const BASE_LISTING = {
id: 'listing-snap-1',
status: 'ACTIVE',
transactionType: 'SALE',
priceVND: '7500000000',
pricePerM2: 93750000,
rentPriceMonthly: null,
commissionPct: 2.5,
viewCount: 42,
saveCount: 7,
inquiryCount: 12,
isFeatured: true,
featuredUntil: '2026-06-01T00:00:00.000Z',
publishedAt: '2026-04-01T00:00:00.000Z',
createdAt: '2026-03-20T00:00:00.000Z',
valuationEstimate: null,
agentQualityScore: { score: 90, tier: 'PLATINUM' },
similarCount: 8,
property: {
id: 'prop-snap-1',
propertyType: 'APARTMENT',
title: 'Căn hộ view sông Q4',
description: 'Căn hộ cao cấp',
address: '1 Nguyễn Văn Linh',
ward: 'Tân Phong',
district: 'Quận 7',
city: 'Hồ Chí Minh',
latitude: 10.725,
longitude: 106.7,
areaM2: 80,
usableAreaM2: 75,
bedrooms: 2,
bathrooms: 2,
floors: null,
floor: 15,
totalFloors: 30,
direction: 'SOUTH',
yearBuilt: 2022,
legalStatus: 'Sổ hồng',
amenities: null,
nearbyPOIs: null,
metroDistanceM: 500,
projectName: 'The River',
furnishing: 'FULL',
propertyCondition: 'NEW',
balconyDirection: 'EAST',
maintenanceFeeVND: '3000000',
parkingSlots: 1,
viewType: ['RIVER'],
petFriendly: true,
suitableFor: ['FAMILY'],
whyThisLocation: 'Vị trí đắc địa',
media: [{ id: 'm-1', url: 'https://cdn.example.com/1.jpg', type: 'image', order: 0, caption: null }],
},
seller: { id: 'seller-snap-1', fullName: 'Trần Thị B', phone: '0911234567' },
agent: { id: 'agent-snap-1', userId: 'user-agent', agency: 'Đất Xanh Group' },
};
const AVM_RESULT = {
estimatedPrice: '7800000000',
confidence: 0.91,
modelVersion: 'v2.1',
comparables: [],
};
function makeHandler(): GetListingHandler {
const mockRepo = { findByIdWithProperty: vi.fn().mockResolvedValue(BASE_LISTING) };
const mockAvm = { estimateValue: vi.fn().mockResolvedValue(AVM_RESULT) };
const mockCache = {
getOrSet: vi.fn().mockImplementation(async (_k: string, fn: () => Promise<unknown>) => fn()),
};
const mockLogger = { log: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() };
return new GetListingHandler(mockRepo as any, mockAvm as any, mockCache as any, mockLogger as any);
}
describe('GET /listings/:id — enriched response snapshot', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(FROZEN_DATE));
});
afterEach(() => {
vi.useRealTimers();
});
it('public caller: inquiryCount is null, all other enrichment fields present', async () => {
const handler = makeHandler();
const result = await handler.execute(new GetListingQuery('listing-snap-1'));
expect(result).toMatchSnapshot();
expect(result!.inquiryCount).toBeNull();
expect(result!.valuationEstimate).toEqual({
value: '7800000000',
confidence: 0.91,
modelVersion: 'v2.1',
estimatedAt: FROZEN_DATE,
});
expect(result!.agentQualityScore).toEqual({ score: 90, tier: 'PLATINUM' });
expect(result!.similarCount).toBe(8);
});
it('owner caller: inquiryCount is visible', async () => {
const handler = makeHandler();
const result = await handler.execute(
new GetListingQuery('listing-snap-1', { userId: 'seller-snap-1', role: 'USER' }),
);
expect(result).toMatchSnapshot();
expect(result!.inquiryCount).toBe(12);
});
it('admin caller: inquiryCount is visible', async () => {
const handler = makeHandler();
const result = await handler.execute(
new GetListingQuery('listing-snap-1', { userId: 'admin-x', role: 'ADMIN' }),
);
expect(result).toMatchSnapshot();
expect(result!.inquiryCount).toBe(12);
});
});

View File

@@ -3,19 +3,36 @@ import { type IListingRepository } from '@modules/listings/domain/repositories/l
import { GetListingHandler } from '../queries/get-listing/get-listing.handler';
import { GetListingQuery } from '../queries/get-listing/get-listing.query';
const baseListingDetail = {
id: 'listing-1',
status: 'ACTIVE',
transactionType: 'SALE',
priceVND: '5000000000',
pricePerM2: 62500000,
rentPriceMonthly: null,
commissionPct: 2.0,
viewCount: 10,
saveCount: 2,
inquiryCount: 5,
isFeatured: false,
featuredUntil: null,
publishedAt: '2026-01-01T00:00:00.000Z',
createdAt: '2026-01-01T00:00:00.000Z',
valuationEstimate: null,
agentQualityScore: { score: 78, tier: 'GOLD' },
similarCount: 3,
property: { id: 'prop-1', title: 'Căn hộ Q1', district: 'Quận 1', city: 'Hồ Chí Minh', areaM2: 80 },
seller: { id: 'seller-1', fullName: 'Nguyễn Văn A', phone: '0901234567' },
agent: { id: 'agent-1', userId: 'user-a', agency: 'Đất Xanh' },
};
describe('GetListingHandler', () => {
let handler: GetListingHandler;
let mockListingRepo: { [K in keyof IListingRepository]: ReturnType<typeof vi.fn> };
let mockAvmService: { estimateValue: ReturnType<typeof vi.fn> };
let mockCache: { getOrSet: ReturnType<typeof vi.fn>; invalidate: ReturnType<typeof vi.fn>; invalidateByPrefix: ReturnType<typeof vi.fn> };
let mockLogger: { log: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; debug: ReturnType<typeof vi.fn> };
const mockListingDetail = {
id: 'listing-1',
status: 'ACTIVE',
price: 5_000_000_000n,
property: { id: 'prop-1', title: 'Căn hộ Q1' },
};
beforeEach(() => {
mockListingRepo = {
findById: vi.fn(),
@@ -25,6 +42,16 @@ describe('GetListingHandler', () => {
search: vi.fn(),
findByStatus: vi.fn(),
findBySellerId: vi.fn(),
findSimilar: vi.fn(),
};
mockAvmService = {
estimateValue: vi.fn().mockResolvedValue({
estimatedPrice: '5200000000',
confidence: 0.87,
modelVersion: 'v2',
comparables: [],
}),
};
mockCache = {
@@ -42,47 +69,52 @@ describe('GetListingHandler', () => {
handler = new GetListingHandler(
mockListingRepo as any,
mockAvmService as any,
mockCache as any,
mockLogger as any,
);
});
it('returns listing detail via cache', async () => {
/**
* Helper: configure cache mock to call through to the provided loader,
* allowing tests to control what the repo / AVM returns.
*/
function callThrough() {
mockCache.getOrSet.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => fn());
mockListingRepo.findByIdWithProperty.mockResolvedValue(mockListingDetail);
}
const query = new GetListingQuery('listing-1');
const result = await handler.execute(query);
it('returns listing detail via cache', async () => {
callThrough();
mockListingRepo.findByIdWithProperty.mockResolvedValue(baseListingDetail);
expect(result).toEqual(mockListingDetail);
const result = await handler.execute(new GetListingQuery('listing-1'));
expect(result).not.toBeNull();
expect(result!.id).toBe('listing-1');
expect(mockCache.getOrSet).toHaveBeenCalled();
});
it('returns null when listing not found', async () => {
mockCache.getOrSet.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => fn());
mockListingRepo.findByIdWithProperty.mockResolvedValue(null);
const query = new GetListingQuery('nonexistent');
const result = await handler.execute(query);
expect(result).toBeNull();
});
it('does not cache not-found results', async () => {
// Simulate getOrSet calling the loader and letting exceptions propagate
mockCache.getOrSet.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => fn());
callThrough();
mockListingRepo.findByIdWithProperty.mockResolvedValue(null);
const result = await handler.execute(new GetListingQuery('nonexistent'));
expect(result).toBeNull();
});
it('does not cache not-found results', async () => {
callThrough();
mockListingRepo.findByIdWithProperty.mockResolvedValue(null);
const result = await handler.execute(new GetListingQuery('nonexistent'));
expect(result).toBeNull();
// The loader throws ListingNotFoundSignal to prevent caching null;
// handler catches it and returns null
});
it('uses cache key with listing id', async () => {
mockCache.getOrSet.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => fn());
mockListingRepo.findByIdWithProperty.mockResolvedValue(mockListingDetail);
callThrough();
mockListingRepo.findByIdWithProperty.mockResolvedValue(baseListingDetail);
await handler.execute(new GetListingQuery('listing-1'));
@@ -97,9 +129,110 @@ describe('GetListingHandler', () => {
it('throws InternalServerErrorException on unexpected errors', async () => {
mockCache.getOrSet.mockRejectedValue(new Error('Redis connection failed'));
const query = new GetListingQuery('listing-1');
await expect(handler.execute(query)).rejects.toThrow(InternalServerErrorException);
await expect(handler.execute(new GetListingQuery('listing-1'))).rejects.toThrow(InternalServerErrorException);
expect(mockLogger.error).toHaveBeenCalled();
});
// ── Enrichment: valuationEstimate ──────────────────────────────────────────
it('attaches valuationEstimate from AVM when available', async () => {
callThrough();
mockListingRepo.findByIdWithProperty.mockResolvedValue(baseListingDetail);
const result = await handler.execute(new GetListingQuery('listing-1'));
expect(result!.valuationEstimate).not.toBeNull();
expect(result!.valuationEstimate!.value).toBe('5200000000');
expect(result!.valuationEstimate!.confidence).toBe(0.87);
expect(result!.valuationEstimate!.modelVersion).toBe('v2');
expect(result!.valuationEstimate!.estimatedAt).toBeDefined();
});
it('returns null valuationEstimate when AVM service throws', async () => {
callThrough();
mockListingRepo.findByIdWithProperty.mockResolvedValue(baseListingDetail);
mockAvmService.estimateValue.mockRejectedValue(new Error('AVM unavailable'));
const result = await handler.execute(new GetListingQuery('listing-1'));
expect(result).not.toBeNull();
expect(result!.valuationEstimate).toBeNull();
expect(mockLogger.warn).toHaveBeenCalled();
});
// ── Enrichment: inquiryCount access gating ─────────────────────────────────
it('exposes inquiryCount to the listing owner', async () => {
callThrough();
mockListingRepo.findByIdWithProperty.mockResolvedValue(baseListingDetail);
const caller = { userId: 'seller-1', role: 'USER' };
const result = await handler.execute(new GetListingQuery('listing-1', caller));
expect(result!.inquiryCount).toBe(5);
});
it('exposes inquiryCount to an ADMIN', async () => {
callThrough();
mockListingRepo.findByIdWithProperty.mockResolvedValue(baseListingDetail);
const caller = { userId: 'admin-user', role: 'ADMIN' };
const result = await handler.execute(new GetListingQuery('listing-1', caller));
expect(result!.inquiryCount).toBe(5);
});
it('hides inquiryCount from anonymous / public callers', async () => {
callThrough();
mockListingRepo.findByIdWithProperty.mockResolvedValue(baseListingDetail);
const result = await handler.execute(new GetListingQuery('listing-1'));
expect(result!.inquiryCount).toBeNull();
});
it('hides inquiryCount from a non-owner authenticated user', async () => {
callThrough();
mockListingRepo.findByIdWithProperty.mockResolvedValue(baseListingDetail);
const caller = { userId: 'other-user', role: 'USER' };
const result = await handler.execute(new GetListingQuery('listing-1', caller));
expect(result!.inquiryCount).toBeNull();
});
// ── Enrichment: agentQualityScore ─────────────────────────────────────────
it('includes agentQualityScore from the base repository result', async () => {
callThrough();
mockListingRepo.findByIdWithProperty.mockResolvedValue(baseListingDetail);
const result = await handler.execute(new GetListingQuery('listing-1'));
expect(result!.agentQualityScore).toEqual({ score: 78, tier: 'GOLD' });
});
it('returns null agentQualityScore when no agent is assigned', async () => {
callThrough();
mockListingRepo.findByIdWithProperty.mockResolvedValue({
...baseListingDetail,
agentQualityScore: null,
agent: null,
});
const result = await handler.execute(new GetListingQuery('listing-1'));
expect(result!.agentQualityScore).toBeNull();
});
// ── Enrichment: similarCount ───────────────────────────────────────────────
it('includes similarCount from the base repository result', async () => {
callThrough();
mockListingRepo.findByIdWithProperty.mockResolvedValue(baseListingDetail);
const result = await handler.execute(new GetListingQuery('listing-1'));
expect(result!.similarCount).toBe(3);
});
});

View File

@@ -1,6 +1,7 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { DomainException, CacheService, CachePrefix, CacheTTL, LoggerService } from '@modules/shared';
import { AVM_SERVICE, type IAVMService } from '@modules/analytics';
import { type ListingDetailData } from '../../../domain/repositories/listing-read.dto';
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
import { GetListingQuery } from './get-listing.query';
@@ -12,6 +13,7 @@ export type ListingDetailDto = ListingDetailData;
export class GetListingHandler implements IQueryHandler<GetListingQuery> {
constructor(
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
@Inject(AVM_SERVICE) private readonly avmService: IAVMService,
private readonly cache: CacheService,
private readonly logger: LoggerService,
) {}
@@ -19,18 +21,23 @@ export class GetListingHandler implements IQueryHandler<GetListingQuery> {
/**
* Returns listing detail or null when not found.
* The controller is responsible for mapping null to a 404 HttpException.
*
* Enrichment added on top of the base repository query:
* - `valuationEstimate` — cached 24 h per listing id; null on AVM failure
* - `inquiryCount` — gated: visible only to owner or ADMIN; public gets null
* - `agentQualityScore` — denormalised from the agent record in the repo query
* - `similarCount` — counted in the repo query
*/
async execute(query: GetListingQuery): Promise<ListingDetailData | null> {
try {
const cacheKey = CacheService.buildKey(CachePrefix.LISTING, query.listingId);
// Check cache first
const cached = await this.cache.getOrSet<ListingDetailData | null>(
// Load base listing (cached 5 min)
const base = await this.cache.getOrSet<ListingDetailData | null>(
cacheKey,
async () => {
const result = await this.listingRepo.findByIdWithProperty(query.listingId);
if (!result) {
// Signal to skip caching by throwing; we catch it below
throw new ListingNotFoundSignal();
}
return result;
@@ -39,7 +46,49 @@ export class GetListingHandler implements IQueryHandler<GetListingQuery> {
'listing',
);
return cached;
// ------------------------------------------------------------------
// AVM valuation — cached separately for 24 h keyed by listing id
// ------------------------------------------------------------------
const valuationCacheKey = CacheService.buildKey(CachePrefix.VALUATION, query.listingId);
let valuationEstimate: ListingDetailData['valuationEstimate'] = null;
try {
valuationEstimate = await this.cache.getOrSet(
valuationCacheKey,
async () => {
const result = await this.avmService.estimateValue({ propertyId: base!.property.id });
const estimate: ListingDetailData['valuationEstimate'] = {
value: result.estimatedPrice,
confidence: result.confidence,
modelVersion: result.modelVersion,
estimatedAt: new Date().toISOString(),
};
return estimate;
},
CacheTTL.VALUATION_LISTING,
'valuation',
);
} catch (avmError) {
// AVM failure is non-fatal — return null, log for observability
this.logger.warn(
`AVM estimation failed for listing ${query.listingId}: ${avmError instanceof Error ? avmError.message : avmError}`,
this.constructor.name,
);
}
// ------------------------------------------------------------------
// Access-gate inquiryCount: only owner or ADMIN may see it
// ------------------------------------------------------------------
const { caller } = query;
const isOwner = caller != null && base!.seller.id === caller.userId;
const isAdmin = caller?.role === 'ADMIN';
const inquiryCount: number | null =
isOwner || isAdmin ? (base!.inquiryCount as number) : null;
return {
...base!,
valuationEstimate,
inquiryCount,
};
} catch (error) {
// Not-found: return null without caching so subsequent requests can find a newly-created listing
if (error instanceof ListingNotFoundSignal) return null;
@@ -61,3 +110,4 @@ class ListingNotFoundSignal extends Error {
this.name = 'ListingNotFoundSignal';
}
}

View File

@@ -1,3 +1,13 @@
export class GetListingQuery {
constructor(public readonly listingId: string) {}
/** Minimal caller context needed for access-gated fields. */
export interface CallerContext {
userId: string;
role: string;
}
export class GetListingQuery {
constructor(
public readonly listingId: string,
/** When omitted the caller is treated as anonymous (public). */
public readonly caller?: CallerContext,
) {}
}

View File

@@ -1,5 +1,19 @@
import { type ListingStatus, type TransactionType, type PropertyType, type Direction, type Furnishing, type PropertyCondition } from '@prisma/client';
/** AVM-based valuation estimate bundled into listing detail. Cached 24 h per listing. */
export interface ValuationEstimate {
value: string;
confidence: number;
modelVersion: string;
estimatedAt: string;
}
/** Agent quality score denormalised from the agent profile. */
export interface AgentQualityScore {
score: number;
tier: 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINUM';
}
/** Returned by findByIdWithProperty — full listing detail with property, seller, agent */
export interface ListingDetailData {
id: string;
@@ -11,11 +25,21 @@ export interface ListingDetailData {
commissionPct: number | null;
viewCount: number;
saveCount: number;
inquiryCount: number;
/**
* Total inquiry count on this listing.
* Visible only to the listing owner or an admin; public callers receive `null`.
*/
inquiryCount: number | null;
isFeatured: boolean;
featuredUntil: string | null;
publishedAt: string | null;
createdAt: string;
/** AVM valuation estimate (cached 24 h). `null` when the AVM service is unavailable. */
valuationEstimate: ValuationEstimate | null;
/** Quality score of the assigned agent. `null` when no agent is assigned. */
agentQualityScore: AgentQualityScore | null;
/** Number of ACTIVE listings matching this one's type / district / price range. */
similarCount: number;
property: {
id: string;
propertyType: PropertyType;

View File

@@ -3,6 +3,14 @@ import { type PrismaService } from '@modules/shared';
import { type ListingDetailData, type ListingSearchItem, type ListingSellerItem, type ListingSimilarItem } from '../../domain/repositories/listing-read.dto';
import { type ListingSearchParams, type PaginatedResult } from '../../domain/repositories/listing.repository';
/** Derive a human-readable tier from a numeric quality score (0100). */
function qualityTier(score: number): 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINUM' {
if (score >= 85) return 'PLATINUM';
if (score >= 70) return 'GOLD';
if (score >= 50) return 'SILVER';
return 'BRONZE';
}
export async function findByIdWithProperty(
prisma: PrismaService,
id: string,
@@ -16,7 +24,7 @@ export async function findByIdWithProperty(
},
},
seller: { select: { id: true, fullName: true, phone: true } },
agent: { select: { id: true, userId: true, agency: true } },
agent: { select: { id: true, userId: true, agency: true, qualityScore: true } },
},
});
@@ -34,6 +42,27 @@ export async function findByIdWithProperty(
// location is NOT NULL in the database — geo extraction always succeeds for existing properties
const geo = geoRows[0]!;
// Count ACTIVE similar listings (same propertyType + district + price ±10% + area ±20%)
const sourcePriceNum = Number(listing.priceVND);
const similarCount = await prisma.listing.count({
where: {
id: { not: id },
status: 'ACTIVE',
priceVND: {
gte: BigInt(Math.floor(sourcePriceNum * 0.9)),
lte: BigInt(Math.ceil(sourcePriceNum * 1.1)),
},
property: {
propertyType: listing.property.propertyType,
district: listing.property.district,
areaM2: {
gte: listing.property.areaM2 * 0.8,
lte: listing.property.areaM2 * 1.2,
},
},
},
});
const now = new Date();
return {
id: listing.id,
@@ -45,11 +74,18 @@ export async function findByIdWithProperty(
commissionPct: listing.commissionPct,
viewCount: listing.viewCount,
saveCount: listing.saveCount,
// inquiryCount is access-gated in the query handler; return raw count here for handler to redact
inquiryCount: listing.inquiryCount,
isFeatured: listing.featuredUntil != null && listing.featuredUntil > now,
featuredUntil: listing.featuredUntil?.toISOString() ?? null,
publishedAt: listing.publishedAt?.toISOString() ?? null,
createdAt: listing.createdAt.toISOString(),
// Enrichment fields — handler populates valuationEstimate; set defaults here
valuationEstimate: null,
agentQualityScore: listing.agent != null
? { score: listing.agent.qualityScore, tier: qualityTier(listing.agent.qualityScore) }
: null,
similarCount,
property: {
id: listing.property.id,
propertyType: listing.property.propertyType,
@@ -93,7 +129,7 @@ export async function findByIdWithProperty(
})),
},
seller: listing.seller,
agent: listing.agent,
agent: listing.agent ? { id: listing.agent.id, userId: listing.agent.userId, agency: listing.agent.agency } : null,
};
}

View File

@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { MulterModule } from '@nestjs/platform-express';
import { AnalyticsModule } from '@modules/analytics';
import { FeatureListingThrottlerGuard } from '@modules/shared';
import { AdminFeatureListingHandler } from './application/commands/admin-feature-listing/admin-feature-listing.handler';
import { BulkUpdateListingsHandler } from './application/commands/bulk-update-listings/bulk-update-listings.handler';
@@ -63,6 +64,7 @@ const EventHandlers = [
@Module({
imports: [
CqrsModule,
AnalyticsModule,
MulterModule.register({
limits: { fileSize: 100 * 1024 * 1024 }, // 100 MB — per-type limits enforced by FileValidationPipe
}),

View File

@@ -28,7 +28,7 @@ import {
import { Throttle } from '@nestjs/throttler';
import type { Response } from 'express';
import * as QRCode from 'qrcode';
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard, OptionalJwtAuthGuard } from '@modules/auth';
import { NotFoundException, ValidationException, EndpointRateLimit, EndpointRateLimitGuard, FeatureListingThrottlerGuard, FileValidationPipe, type UploadedFile as ValidatedFile } from '@modules/shared';
import { RequireQuota, QuotaGuard } from '@modules/subscriptions';
import { BulkUpdateListingsCommand } from '../../application/commands/bulk-update-listings/bulk-update-listings.command';
@@ -237,9 +237,14 @@ export class ListingsController {
@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' })
@UseGuards(OptionalJwtAuthGuard)
@Get(':id')
async getListing(@Param('id') id: string): Promise<ListingDetailData> {
const result = await this.queryBus.execute(new GetListingQuery(id));
async getListing(
@Param('id') id: string,
@CurrentUser() user?: JwtPayload,
): Promise<ListingDetailData> {
const caller = user ? { userId: user.sub, role: user.role } : undefined;
const result = await this.queryBus.execute(new GetListingQuery(id, caller));
if (!result) {
throw new NotFoundException('Listing', id);
}