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,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;
}
}