From 805aaeffadbc3b307ee1c8a15489bc69706068e4 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Tue, 21 Apr 2026 02:43:56 +0700 Subject: [PATCH] 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 --- apps/api/src/modules/analytics/index.ts | 2 + apps/api/src/modules/auth/index.ts | 1 + .../guards/optional-jwt-auth.guard.ts | 21 ++ ...t-listing-enrichment.snapshot.spec.ts.snap | 265 ++++++++++++++++++ .../get-listing-enrichment.snapshot.spec.ts | 135 +++++++++ .../__tests__/get-listing.handler.spec.ts | 195 +++++++++++-- .../get-listing/get-listing.handler.ts | 58 +++- .../queries/get-listing/get-listing.query.ts | 14 +- .../domain/repositories/listing-read.dto.ts | 26 +- .../repositories/listing-read.queries.ts | 40 ++- .../src/modules/listings/listings.module.ts | 2 + .../controllers/listings.controller.ts | 11 +- 12 files changed, 727 insertions(+), 43 deletions(-) create mode 100644 apps/api/src/modules/auth/presentation/guards/optional-jwt-auth.guard.ts create mode 100644 apps/api/src/modules/listings/application/__tests__/__snapshots__/get-listing-enrichment.snapshot.spec.ts.snap create mode 100644 apps/api/src/modules/listings/application/__tests__/get-listing-enrichment.snapshot.spec.ts diff --git a/apps/api/src/modules/analytics/index.ts b/apps/api/src/modules/analytics/index.ts index e926702..17cc064 100644 --- a/apps/api/src/modules/analytics/index.ts +++ b/apps/api/src/modules/analytics/index.ts @@ -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'; diff --git a/apps/api/src/modules/auth/index.ts b/apps/api/src/modules/auth/index.ts index 4b2d980..ff194e0 100644 --- a/apps/api/src/modules/auth/index.ts +++ b/apps/api/src/modules/auth/index.ts @@ -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'; diff --git a/apps/api/src/modules/auth/presentation/guards/optional-jwt-auth.guard.ts b/apps/api/src/modules/auth/presentation/guards/optional-jwt-auth.guard.ts new file mode 100644 index 0000000..ada642f --- /dev/null +++ b/apps/api/src/modules/auth/presentation/guards/optional-jwt-auth.guard.ts @@ -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(_err: unknown, user: TUser): TUser { + // Return whatever passport resolved (may be false/undefined for anonymous requests) + return user; + } +} diff --git a/apps/api/src/modules/listings/application/__tests__/__snapshots__/get-listing-enrichment.snapshot.spec.ts.snap b/apps/api/src/modules/listings/application/__tests__/__snapshots__/get-listing-enrichment.snapshot.spec.ts.snap new file mode 100644 index 0000000..3883453 --- /dev/null +++ b/apps/api/src/modules/listings/application/__tests__/__snapshots__/get-listing-enrichment.snapshot.spec.ts.snap @@ -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, +} +`; diff --git a/apps/api/src/modules/listings/application/__tests__/get-listing-enrichment.snapshot.spec.ts b/apps/api/src/modules/listings/application/__tests__/get-listing-enrichment.snapshot.spec.ts new file mode 100644 index 0000000..a657abc --- /dev/null +++ b/apps/api/src/modules/listings/application/__tests__/get-listing-enrichment.snapshot.spec.ts @@ -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) => 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); + }); +}); diff --git a/apps/api/src/modules/listings/application/__tests__/get-listing.handler.spec.ts b/apps/api/src/modules/listings/application/__tests__/get-listing.handler.spec.ts index f601112..0aee8c8 100644 --- a/apps/api/src/modules/listings/application/__tests__/get-listing.handler.spec.ts +++ b/apps/api/src/modules/listings/application/__tests__/get-listing.handler.spec.ts @@ -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 }; + let mockAvmService: { estimateValue: ReturnType }; let mockCache: { getOrSet: ReturnType; invalidate: ReturnType; invalidateByPrefix: ReturnType }; let mockLogger: { log: ReturnType; error: ReturnType; warn: ReturnType; debug: ReturnType }; - 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) => 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) => 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) => 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) => 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); + }); }); diff --git a/apps/api/src/modules/listings/application/queries/get-listing/get-listing.handler.ts b/apps/api/src/modules/listings/application/queries/get-listing/get-listing.handler.ts index fc23f8c..2dba262 100644 --- a/apps/api/src/modules/listings/application/queries/get-listing/get-listing.handler.ts +++ b/apps/api/src/modules/listings/application/queries/get-listing/get-listing.handler.ts @@ -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 { 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 { /** * 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 { try { const cacheKey = CacheService.buildKey(CachePrefix.LISTING, query.listingId); - // Check cache first - const cached = await this.cache.getOrSet( + // Load base listing (cached 5 min) + const base = await this.cache.getOrSet( 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 { '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'; } } + diff --git a/apps/api/src/modules/listings/application/queries/get-listing/get-listing.query.ts b/apps/api/src/modules/listings/application/queries/get-listing/get-listing.query.ts index 9d64498..2a82a25 100644 --- a/apps/api/src/modules/listings/application/queries/get-listing/get-listing.query.ts +++ b/apps/api/src/modules/listings/application/queries/get-listing/get-listing.query.ts @@ -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, + ) {} } diff --git a/apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts b/apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts index 850bb16..806d7b4 100644 --- a/apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts +++ b/apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts @@ -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; diff --git a/apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts b/apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts index a256f8b..ecdaa43 100644 --- a/apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts +++ b/apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts @@ -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, }; } diff --git a/apps/api/src/modules/listings/listings.module.ts b/apps/api/src/modules/listings/listings.module.ts index b044a36..cc2af3f 100644 --- a/apps/api/src/modules/listings/listings.module.ts +++ b/apps/api/src/modules/listings/listings.module.ts @@ -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 }), diff --git a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts index 76a5386..018e44c 100644 --- a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts +++ b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts @@ -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 { - const result = await this.queryBus.execute(new GetListingQuery(id)); + async getListing( + @Param('id') id: string, + @CurrentUser() user?: JwtPayload, + ): Promise { + 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); }