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:
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
`;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (0–100). */
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user