Compare commits

...

33 Commits

Author SHA1 Message Date
Ho Ngoc Hai
a6d1ef307c Merge branch 'task/tec-2759-ws-residential-events' into master
Some checks failed
Deploy / Build API Image (push) Failing after 22s
Deploy / Build Web Image (push) Failing after 9s
Deploy / Build AI Services Image (push) Failing after 8s
E2E Tests / Playwright E2E (push) Failing after 16s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — Web Image (push) Failing after 33s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 31s
Deploy / Smoke Test Staging (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 0s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 10s
CI / E2E Tests (push) Has been skipped
Security Scanning / Trivy Scan — API Image (push) Failing after 52s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 34m44s
Security Scanning / Trivy Filesystem Scan (push) Failing after 37s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
2026-04-18 20:38:27 +07:00
Ho Ngoc Hai
38b9def99a feat: implement project development module, transfer management features, and industrial AVM model integration 2026-04-18 20:34:35 +07:00
Ho Ngoc Hai
0f3b4d7b0d feat(messaging): R8.4 add missing Conversation/Message migration (TEC-2767)
Schema models cho Conversation + ConversationParticipant + Message đã
được thêm trong commit 3b5da2d nhưng chưa có migration tương ứng. Bổ
sung migration để DB ready cho in-app messaging (REST + WS /messaging).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-18 15:42:56 +07:00
Ho Ngoc Hai
caa0a58afd feat(notifications): R8.1 Stringee SMS adapter + rate limiting (TEC-2764)
- Add NotificationChannelPort domain port for SMS/transactional channels.
- Refactor StringeeSmsService to implement the port; routes OTP template
  keys through the tighter otp bucket and transactional keys through the
  wider bucket.
- Add SmsRateLimiterService using a Redis sorted-set sliding window with
  per-minute + per-hour limits per phone; fails open on Redis errors.
- Rate-limit violations throw DomainException(TOO_MANY_REQUESTS, 429)
  with retryAfterSeconds in the details payload.
- Cover adapter + rate limiter with unit tests (22 specs); all 148
  notifications tests still green.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-18 15:37:45 +07:00
Ho Ngoc Hai
8c6e3b92d0 feat(notifications): R2.8 residential WS events (TEC-2759)
- Add emitResidentialEvent helper on NotificationsGateway that fans
  residential:price-drop, residential:new-listing-in-project, and
  residential:inquiry-reply to the user's /notifications room.
- Wire three CQRS @EventsHandler listeners on ListingPriceChangedEvent
  (only when newPrice < oldPrice, match saved searches),
  ListingApprovedEvent (match saved searches with filters.projectId
  against property.projectDevelopmentId), and InquiryReadEvent
  (notify inquiry author).
- Redis pub/sub fan-out already handled by RedisIoAdapter from
  TEC-2766, so these broadcasts work across API instances.
- Unit tests for all three listeners and the new gateway helper.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-18 15:28:40 +07:00
Ho Ngoc Hai
729afe2db6 feat(ai-services): dedicated GET /avm/v2/feature-importance endpoint (TEC-2760)
Exposes ensemble feature importance as a standalone endpoint per R5.1 spec.
Aggregates XGBoost (0.4) + LightGBM (0.35) + CatBoost (0.25) gain when trained
boosters are loaded; falls back to the curated heuristic ranking otherwise, so
callers can depend on the endpoint during scaffold/heuristic-only runs.

- Factored heuristic drivers into a shared constant (_HEURISTIC_DRIVERS)
- Added AVMv2FeatureImportanceResponse model (model_version + source + drivers)
- Added service.get_feature_importance() public method
- Added tests/test_avm_v2.py::test_feature_importance_heuristic (24 total pass)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-18 15:27:30 +07:00
Ho Ngoc Hai
5731577fa9 feat(listings): R2.3 featured listings entitlement + admin promote + search filter (TEC-2754)
- Add Plan.featuredListingsQuota (Int?) with per-tier seed (FREE=0, AGENT_PRO=5, INVESTOR=10, ENTERPRISE unlimited) and migration 20260418000000_add_featured_listings_quota
- Wire featured_listings_promoted metric into CheckQuotaHandler METRIC_TO_PLAN_FIELD so QuotaGuard honors the new quota
- Add PromoteFeaturedListingCommand + handler (entitlement-based, no payment): verifies ownership/agent, checks quota, extends featuredUntil, meters usage
- Add POST /listings/:id/promote endpoint gated by @RequireQuota('featured_listings_promoted') + QuotaGuard
- Add AdminFeatureListingCommand + handler with LISTING_FEATURED / LISTING_UNFEATURED audit log entries (new AdminAction enum values) and transactional write
- Add POST /admin/moderation/listings/:id/feature endpoint (ADMIN-only) with reason + duration
- Expose featured?: boolean filter on SearchPropertiesDto -> isFeatured:=1|0 Typesense filter in SearchPropertiesHandler
- Unit tests: 8 for PromoteFeaturedListingHandler, 6 for AdminFeatureListingHandler, 3 for search featured filter

Keeps existing pay-per-feature FeatureListingHandler intact for backward compatibility.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-18 15:18:04 +07:00
Ho Ngoc Hai
580eb2a261 feat(web): residential_projects feature flag for /du-an routes (TEC-2757)
- Add useResidentialProjectsFlag hook with NEXT_PUBLIC_FEATURE_RESIDENTIAL_PROJECTS env + URL/localStorage override (mirrors AVM v2 pattern)
- Gate /du-an index (client) and /du-an/[slug] detail (server) routes via notFound() when flag disabled
- Add component tests for index page including disabled-flag notFound branch

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-18 15:13:06 +07:00
Ho Ngoc Hai
2c1e3771e9 feat(analytics): add Python NeighborhoodScore service + NestJS HTTP proxy (TEC-2756)
- libs/ai-services: new POST /neighborhood/score router computing weighted
  6-axis livability score from per-category POI counts; algorithm versioned
  for future iteration (sigmoid curves, percentile thresholds).
- apps/api: HttpNeighborhoodScoreService proxies to Python first, falls back
  to PrismaNeighborhoodScoreService when AI service unavailable. Mirrors the
  HttpAVMService pattern. Existing GET /analytics/neighborhoods/:district/score
  endpoint and CQRS handler now flow through the proxy.
- AnalyticsModule binds Http variant by default, retains Prisma variant as
  injectable fallback.
- Tests: 5 pytest cases for Python heuristic, 4 vitest cases for HTTP proxy
  fallback behaviour.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-18 15:07:02 +07:00
Ho Ngoc Hai
329a821b4a feat(notifications): production-ready WebSocket gateway (TEC-2766)
- Add RedisIoAdapter (shared/infra) for multi-instance Socket.IO fan-out
  with graceful fallback to the in-memory IoAdapter when Redis is
  unreachable.
- Pin Socket.IO heartbeat (pingInterval/pingTimeout/connectTimeout)
  via env-tunable gateway options for reconnect stability.
- Expose Prometheus metrics on /notifications: goodgo_ws_connected_clients
  (Gauge) and goodgo_ws_messages_total (Counter) with namespace/event/
  direction labels. Wired through MetricsService and tracked across
  connect/disconnect + emits.
- Unit tests: RedisIoAdapter connect/fallback/close, new MetricsService
  WS helpers, and gateway metric increments/decrements on auth paths.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-18 15:06:25 +07:00
Ho Ngoc Hai
5d4ecdeb2f feat(web): AVM v2 upgraded valuation dashboard (TEC-2763)
R5.4 ships the upgraded AVM UI behind the `avm_v2` A/B flag. When the
flag is on, the dashboard exposes:

- Tab switch between single valuation and multi-property compare
- Waterfall drivers chart (ValueDriversChart) alongside the existing
  horizontal bar breakdown
- Mapbox comparables map with similarity-coloured markers and an
  optional highlighted subject pin
- Confidence interval + range bar and PDF export remain available
- Valuation history chart surface unchanged (still lazy-loaded)

Flag plumbing (useAvmV2Flag):
- NEXT_PUBLIC_FEATURE_AVM_V2=1 enables by default
- `?avm_v2=1|0` URL param forces + persists to localStorage
- safe localStorage handling (no throw when storage is blocked)

Tests: comparables-map, value-drivers-chart, use-avm-v2-flag specs
added. Pre-existing "Yếu tố chính" assertion in valuation-results.spec
updated to match the current copy ("Yếu tố ảnh hưởng giá") so the
valuation suite is green (7 files, 52 tests).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-18 15:05:46 +07:00
Ho Ngoc Hai
e18390ead9 feat(auth): add phoneNumber to profile update with SMS OTP re-verify
TEC-2722 — PATCH /api/v1/auth/profile now accepts phoneNumber alongside
fullName, avatarUrl, and email. Phone changes are deferred until the user
confirms the SMS OTP via POST /api/v1/auth/profile/verify-phone, mirroring
the existing email-change OTP flow.

- Add PhoneChangeRequestedEvent + user.phone_change_otp SMS template
- Add VerifyPhoneChangeHandler with Redis-backed 10-minute OTP
- Re-check phone uniqueness at verify time to catch races
- Extend unit tests for UpdateProfileHandler + add VerifyPhoneChangeHandler spec

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-18 00:17:12 +07:00
Ho Ngoc Hai
78e46a024b feat(web): enhance KYC upload with validation, previews, test ids
- Add file type (JPG/PNG/WEBP/PDF) and 5MB size validation
- Show image previews with cleanup of object URLs
- Add data-testid attributes on inputs, buttons, previews, alerts for E2E
- Improve error messaging for expired/failed presigned uploads (403 vs other)
- Guard step 2->3 advance when front image missing

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-18 00:06:13 +07:00
Ho Ngoc Hai
b21f197c09 feat(notifications): add Zalo OA webhook controller + WebSocket gateway tests
- Add ZaloOaWebhookController: GET verification endpoint, POST event handler
  for follow/unfollow/user_send_text events with user linking via OAuthAccount
- Register webhook controller in NotificationsModule
- Add 13 unit tests for webhook (challenge verify, follow/unfollow/message
  handling, linked/unlinked users, error resilience)
- Add 18 unit tests for NotificationsGateway (JWT auth, multi-device tracking,
  disconnect cleanup, notification.sent event, Redis cache, unread count)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 18:31:02 +07:00
Ho Ngoc Hai
8e9d021465 feat: add unit tests for featured listings, neighborhood scores + price history chart
- Add unit tests for FeatureListingHandler (6 tests) and ActivateFeaturedListingHandler (6 tests)
- Add unit tests for NeighborhoodScoreServiceImpl (5 tests) and GetNeighborhoodScoreHandler (2 tests)
- Add PriceHistoryChart component with recharts LineChart for listing detail page
- Wire up price history API client and integrate chart into listing detail view

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 18:21:44 +07:00
Ho Ngoc Hai
0dda2bffdb feat(api): add POST /avm/industrial endpoint for industrial rent estimation
Wire NestJS controller to Python AI service's industrial AVM. Adds CQRS
query/handler, Swagger-annotated DTOs, AI client method, and 7 unit tests
covering parameter mapping, response camelCase conversion, and error handling.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 18:01:23 +07:00
Ho Ngoc Hai
9eaec46a37 feat(ai-services): AVM v2 residential — expanded features, training pipeline, model versioning
Add neighborhood_score, developer_reputation, floor_level, direction premiums
to the multi-model ensemble. Implement real Optuna-based training pipeline
for XGBoost/LightGBM/CatBoost with grouped train/val/test splits. Add
file-based model registry with rollback and list-versions endpoints.
23 Python tests covering all new features.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 17:55:03 +07:00
Ho Ngoc Hai
6cf2c23170 feat(listings): add source field to PriceHistory + unit tests
- Add `source` column to PriceHistory Prisma model (manual_update, admin_override, market_adjustment)
- Add migration for the new column with default 'manual_update'
- Update ListingPriceChangedEvent domain event with optional source parameter
- Update RecordPriceHistoryHandler to persist source
- Update GetPriceHistoryHandler to return source in query results
- Add unit tests for RecordPriceHistoryHandler (5 cases)
- Add unit tests for GetPriceHistoryHandler (3 cases)
- Add ListingPriceChangedEvent tests to domain events spec (4 cases)
- Add getPriceHistory controller tests (2 cases)

All 1805 tests pass, typecheck clean.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 17:43:48 +07:00
Ho Ngoc Hai
f3a2a012c4 feat(web): add price range filter and list view to /du-an page
Add minPrice/maxPrice inputs to ProjectFilterBar and introduce a
list view mode alongside the existing grid/map toggle for project
browsing.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 17:40:30 +07:00
Ho Ngoc Hai
a6e53e3d06 feat(ai-services): add AVM v2 A/B comparison endpoint and tests
Add POST /avm/v2/compare-v1 endpoint that runs both v1 (single-model)
and v2 (ensemble) AVM predictions on the same property and returns a
side-by-side comparison with price diff, confidence delta, and a
recommendation on which model to prefer.

- ABComparisonRequest/Response schemas in avm_v2 models
- compare_v1() method in AVMv2EnsembleService
- 4 new integration tests for the comparison endpoint
- All 47 Python tests pass

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 17:35:30 +07:00
Ho Ngoc Hai
74804757c5 test(analytics): add unit tests for AVM batch, history, comparison endpoints
Add comprehensive test coverage for the three AVM API upgrade endpoints:
- BatchValuationHandler: batch results, partial failures, error handling
- ValuationHistoryHandler: history retrieval, limit, empty state, errors
- ValuationComparisonHandler: multi-property compare, summary, edge cases
- AnalyticsController: route-level tests for all new endpoints

Fix async error handling in handlers by adding await to cache.getOrSet
calls so try/catch blocks properly catch rejections.

Fix pre-existing web test failures: add missing FLOOD_RISK_OPTIONS and
QUALITY_LABELS to valuation-form mock, update valuation-results assertions
to match current component rendering.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 17:28:38 +07:00
Ho Ngoc Hai
ac4191cdf0 test(reports): add E2E pipeline integration tests for report generation
26 tests covering: full pipeline flow for 3 report types + generic fallback,
status polling (GENERATING → READY/FAILED transitions), quota enforcement and
user scoping, error handling (PDF failure, AI failure, auth checks), delete
cleanup flow, and temp file lifecycle.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 17:24:52 +07:00
Ho Ngoc Hai
8f2d325d60 feat(industrial): add IndustrialListing CRUD endpoints + Typesense indexing
Wire full DDD stack for IndustrialListing: domain entity, repository interface,
CQRS commands/queries with handlers, Prisma repository, Typesense sync on
create/update/delete, controller with 5 REST endpoints, and validated DTOs.
Register all providers in IndustrialModule.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 17:08:08 +07:00
Ho Ngoc Hai
13bd76ac5d feat(ai-services): add building_coverage, loading_docks, zoning to industrial AVM
Completes the industrial-specific feature set required for AVM industrial
valuation. Adds heuristic adjustments for all three new features and
4 new tests covering zoning premiums, loading docks, and coverage ratio.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 17:06:27 +07:00
Ho Ngoc Hai
8592fb436c feat(web): integrate neighborhood radar chart into listing detail page
Add NeighborhoodRadarChart to listing detail view, fetching scores
from the analytics API based on the listing's district and city.
Displays a 6-axis radar chart (education, healthcare, transport,
shopping, environment, safety) with overall score and color-coded
badges.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 17:05:26 +07:00
Ho Ngoc Hai
24a2fd1369 fix(web,prisma): fix TypeScript errors in transfer wizard and schema
- Fix Zod v4 enum API: replace deprecated `required_error` with `error`
- Create missing TransferWizardClient component (4-step wizard: category, items, AI estimate, submit)
- Add CANCELLED status to TransferListingStatus enum for soft-delete support

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 17:02:20 +07:00
Ho Ngoc Hai
a7bcc807ad feat(transfer): add DELETE endpoint, domain events, and event-driven Typesense sync
- DeleteTransferListingCommand/Handler with seller authorization and soft delete (→ CANCELLED)
- Domain events: TransferListingCreated/Updated/DeletedEvent with EventEmitter2
- Event handler: TransferListingTypesenseHandler syncs Typesense on all CUD operations
- Create/Update handlers now emit domain events after persistence
- DELETE /transfer/listings/:id controller endpoint with JWT auth

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 15:27:57 +07:00
Ho Ngoc Hai
ca41f7e604 feat(transfer): add Claude Vision condition assessment for transfer pricing
Add POST /transfer/estimate-from-photos endpoint that uses Claude Vision API
to assess furniture/appliance condition from photos, integrating with the
existing rule-based pricing engine. Includes rate limiting (5/min), image hash
caching, graceful fallback, and 17 unit tests covering all paths.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 14:41:32 +07:00
Ho Ngoc Hai
b22543d59e feat(seed): add MacroeconomicData and InfrastructureProject seed data
Add seed-macro-infra.ts with 144 macroeconomic data points (HCMC + Hanoi,
6 indicators, quarterly 2023-2025) and 15 infrastructure projects with
PostGIS coordinates (Metro Line 1, Thu Duc Innovation District, Ring Road 3,
Long Thanh Airport, Can Gio Bridge, etc.). Integrated into main seed pipeline.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 14:18:41 +07:00
Ho Ngoc Hai
57db3fe388 test(auth): add unit tests for KYC presigned upload and submit handlers
Cover GenerateKycUploadUrlsHandler (10 tests) and SubmitKycHandler (10 tests):
presigned URL flow, legacy file upload, status validation, error handling.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 13:19:44 +07:00
Ho Ngoc Hai
5810f0be56 feat(web): add industrial compare page, listing search, and Mapbox park map
- Add interactive Mapbox map to /khu-cong-nghiep landing page with park markers and popups
- Build compare page at /khu-cong-nghiep/so-sanh with recharts RadarChart and detailed comparison table
- Build listing search page at /khu-cong-nghiep/cho-thue with filters for property type, lease type, area, and price
- Add IndustrialListing types, API client functions, and React Query hooks

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 12:40:35 +07:00
Ho Ngoc Hai
28cdd92846 test(listings): add updateListing controller tests for PATCH /api/v1/listings/:id
Cover the updateListing controller method: basic command dispatch and
full-field update with re-moderation flag.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 11:41:29 +07:00
Ho Ngoc Hai
44533a88f4 fix(web): wire up inquiry modal toast notification on listing detail page
The "Nhắn tin" button's inquiry modal now shows a success toast via
sonner after submission instead of an in-dialog success state, and
closes the modal automatically. Added sonner as a dependency and
mounted <Toaster> in the root locale layout.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 10:56:56 +07:00
262 changed files with 24046 additions and 376 deletions

View File

@@ -37,6 +37,7 @@
"@prisma/client": "^7.7.0",
"@sentry/nestjs": "^10.47.0",
"@sentry/profiling-node": "^10.47.0",
"@socket.io/redis-adapter": "^8.3.0",
"@willsoto/nestjs-prometheus": "^6.1.0",
"bcrypt": "^6.0.0",
"bullmq": "^5.74.1",

View File

@@ -8,11 +8,10 @@ import './instrument';
import { RequestMethod, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import cookieParser from 'cookie-parser';
import helmet from 'helmet';
import { LoggerService, validateEnv } from '@modules/shared';
import { LoggerService, RedisIoAdapter, validateEnv } from '@modules/shared';
import { AppModule } from './app.module';
async function bootstrap() {
@@ -60,7 +59,11 @@ async function bootstrap() {
});
// ── WebSocket Adapter (Socket.IO) ──
app.useWebSocketAdapter(new IoAdapter(app));
// Redis pub/sub fan-out for multi-instance broadcasts; falls back to the
// in-memory IoAdapter when Redis is unreachable (single-node / local dev).
const wsAdapter = new RedisIoAdapter(app);
await wsAdapter.connectToRedis();
app.useWebSocketAdapter(wsAdapter);
// ── Security Headers (Helmet) ──
app.use(

View File

@@ -2,13 +2,19 @@ import {
Body,
Controller,
Get,
Ip,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam, ApiQuery } from '@nestjs/swagger';
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
import {
AdminFeatureListingCommand,
type AdminFeatureListingResult,
} from '@modules/listings';
import { ApproveKycCommand } from '../../application/commands/approve-kyc/approve-kyc.command';
import { type ApproveKycResult } from '../../application/commands/approve-kyc/approve-kyc.handler';
import { ApproveListingCommand } from '../../application/commands/approve-listing/approve-listing.command';
@@ -25,6 +31,7 @@ import {
type ModerationQueueResult,
type KycQueueResult,
} from '../../domain/repositories/admin-query.repository';
import { type AdminFeatureListingDto } from '../dto/admin-feature-listing.dto';
import { type ApproveKycDto } from '../dto/approve-kyc.dto';
import { type ApproveListingDto } from '../dto/approve-listing.dto';
import { type BulkModerateDto } from '../dto/bulk-moderate.dto';
@@ -105,6 +112,33 @@ export class AdminModerationController {
);
}
@Post('listings/:id/feature')
@ApiOperation({
summary: 'Admin: feature or unfeature a listing manually (audited, no payment)',
})
@ApiParam({ name: 'id', description: 'Listing UUID' })
@ApiResponse({ status: 201, description: 'Listing featured state updated successfully' })
@ApiResponse({ status: 400, description: 'Validation error' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async adminFeatureListing(
@Param('id') id: string,
@Body() dto: AdminFeatureListingDto,
@CurrentUser() user: JwtPayload,
@Ip() ip: string,
): Promise<AdminFeatureListingResult> {
return this.commandBus.execute(
new AdminFeatureListingCommand(
id,
user.sub,
dto.action,
dto.durationDays ?? null,
dto.reason,
ip ?? null,
),
);
}
// ── KYC ──
@Get('kyc')

View File

@@ -0,0 +1,36 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsString, MinLength, ValidateIf } from 'class-validator';
const ALLOWED_DURATIONS = [3, 7, 14, 30, 60, 90] as const;
export type AdminFeatureDuration = (typeof ALLOWED_DURATIONS)[number];
export class AdminFeatureListingDto {
@ApiProperty({
enum: ['feature', 'unfeature'],
example: 'feature',
description: 'Bật hoặc gỡ tin nổi bật thủ công',
})
@IsIn(['feature', 'unfeature'])
action!: 'feature' | 'unfeature';
@ApiPropertyOptional({
enum: ALLOWED_DURATIONS,
example: 7,
description: 'Số ngày featured (bắt buộc khi action=feature)',
})
@ValidateIf((o: AdminFeatureListingDto) => o.action === 'feature')
@Type(() => Number)
@IsInt()
@IsIn([...ALLOWED_DURATIONS])
@IsOptional()
durationDays?: AdminFeatureDuration;
@ApiProperty({
example: 'Đền bù lỗi hiển thị featured trong 3 ngày qua',
description: 'Lý do cho audit log (tối thiểu 5 ký tự)',
})
@IsString()
@MinLength(5)
reason!: string;
}

View File

@@ -5,6 +5,7 @@ import { TrackEventHandler } from './application/commands/track-event/track-even
import { UpdateMarketIndexHandler } from './application/commands/update-market-index/update-market-index.handler';
import { ListingCreatedModerationHandler } from './application/event-handlers/listing-created-moderation.handler';
import { BatchValuationHandler } from './application/queries/batch-valuation/batch-valuation.handler';
import { IndustrialValuationHandler } from './application/queries/industrial-valuation/industrial-valuation.handler';
import { GetDistrictStatsHandler } from './application/queries/get-district-stats/get-district-stats.handler';
import { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap.handler';
import { GetMarketReportHandler } from './application/queries/get-market-report/get-market-report.handler';
@@ -22,9 +23,13 @@ import { PrismaValuationRepository } from './infrastructure/repositories/prisma-
import { AI_SERVICE_CLIENT, AiServiceClient } from './infrastructure/services/ai-service.client';
import { HttpAVMService } from './infrastructure/services/http-avm.service';
import { MarketIndexCronService } from './infrastructure/services/market-index-cron.service';
import { NeighborhoodScoreServiceImpl } from './infrastructure/services/neighborhood-score.service';
import {
HttpNeighborhoodScoreService,
PrismaNeighborhoodScoreService,
} from './infrastructure/services/neighborhood-score.service';
import { PrismaAVMService } from './infrastructure/services/prisma-avm.service';
import { AnalyticsController } from './presentation/controllers/analytics.controller';
import { AvmController } from './presentation/controllers/avm.controller';
const CommandHandlers = [
TrackEventHandler,
@@ -42,6 +47,7 @@ const QueryHandlers = [
ValuationHistoryHandler,
ValuationComparisonHandler,
GetNeighborhoodScoreHandler,
IndustrialValuationHandler,
];
const EventHandlers = [
@@ -50,7 +56,7 @@ const EventHandlers = [
@Module({
imports: [CqrsModule],
controllers: [AnalyticsController],
controllers: [AnalyticsController, AvmController],
providers: [
// AI service client
{ provide: AI_SERVICE_CLIENT, useClass: AiServiceClient },
@@ -63,8 +69,9 @@ const EventHandlers = [
PrismaAVMService,
{ provide: AVM_SERVICE, useClass: HttpAVMService },
// Neighborhood scoring
{ provide: NEIGHBORHOOD_SCORE_SERVICE, useClass: NeighborhoodScoreServiceImpl },
// Neighborhood scoring: HTTP proxy → Python AI service, falls back to Prisma scoring
PrismaNeighborhoodScoreService,
{ provide: NEIGHBORHOOD_SCORE_SERVICE, useClass: HttpNeighborhoodScoreService },
// Cron
MarketIndexCronService,

View File

@@ -0,0 +1,109 @@
import { InternalServerErrorException } from '@nestjs/common';
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
import { DomainException } from '@modules/shared';
import {
type IAVMService,
type BatchValuationResult,
type ValuationResult,
} from '../../domain/services/avm-service';
import { BatchValuationHandler } from '../queries/batch-valuation/batch-valuation.handler';
import { BatchValuationQuery } from '../queries/batch-valuation/batch-valuation.query';
describe('BatchValuationHandler', () => {
let handler: BatchValuationHandler;
let mockAvm: { [K in keyof IAVMService]: ReturnType<typeof vi.fn> };
let mockLogger: { error: ReturnType<typeof vi.fn> };
const sampleValuation: ValuationResult = {
estimatedPrice: '5000000000',
confidence: 0.85,
pricePerM2: 75000000,
comparables: [],
modelVersion: 'avm-v1.0',
};
const sampleBatchResult: BatchValuationResult[] = [
{ propertyId: 'prop-1', valuation: sampleValuation },
{ propertyId: 'prop-2', valuation: sampleValuation },
];
beforeEach(() => {
mockAvm = {
estimateValue: vi.fn(),
getComparables: vi.fn(),
estimateBatch: vi.fn(),
};
mockLogger = { error: vi.fn() };
const mockCache = {
getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()),
} as unknown as CacheService;
handler = new BatchValuationHandler(
mockAvm as any,
mockCache,
mockLogger as any,
);
});
it('returns batch valuation results for multiple properties', async () => {
mockAvm.estimateBatch.mockResolvedValue(sampleBatchResult);
const query = new BatchValuationQuery(['prop-1', 'prop-2']);
const result = await handler.execute(query);
expect(result).toHaveLength(2);
expect(result[0]!.propertyId).toBe('prop-1');
expect(result[0]!.valuation!.estimatedPrice).toBe('5000000000');
expect(result[1]!.propertyId).toBe('prop-2');
expect(mockAvm.estimateBatch).toHaveBeenCalledWith([
{ propertyId: 'prop-1' },
{ propertyId: 'prop-2' },
]);
});
it('handles partial failures in batch results', async () => {
const partialResult: BatchValuationResult[] = [
{ propertyId: 'prop-1', valuation: sampleValuation },
{ propertyId: 'prop-2', valuation: null, error: 'Not found' },
];
mockAvm.estimateBatch.mockResolvedValue(partialResult);
const query = new BatchValuationQuery(['prop-1', 'prop-2']);
const result = await handler.execute(query);
expect(result).toHaveLength(2);
expect(result[0]!.valuation).not.toBeNull();
expect(result[1]!.valuation).toBeNull();
expect(result[1]!.error).toBe('Not found');
});
it('re-throws DomainException directly', async () => {
const domainError = new DomainException('VALIDATION_ERROR', 'Invalid input');
mockAvm.estimateBatch.mockRejectedValue(domainError);
const query = new BatchValuationQuery(['prop-1']);
await expect(handler.execute(query)).rejects.toThrow(DomainException);
});
it('wraps unexpected errors in InternalServerErrorException', async () => {
mockAvm.estimateBatch.mockRejectedValue(new Error('Database timeout'));
const query = new BatchValuationQuery(['prop-1']);
await expect(handler.execute(query)).rejects.toThrow(InternalServerErrorException);
expect(mockLogger.error).toHaveBeenCalled();
});
it('passes single property batch correctly', async () => {
const singleResult: BatchValuationResult[] = [
{ propertyId: 'prop-solo', valuation: sampleValuation },
];
mockAvm.estimateBatch.mockResolvedValue(singleResult);
const query = new BatchValuationQuery(['prop-solo']);
const result = await handler.execute(query);
expect(result).toHaveLength(1);
expect(result[0]!.propertyId).toBe('prop-solo');
});
});

View File

@@ -0,0 +1,51 @@
import { type INeighborhoodScoreService, type NeighborhoodScoreResult } from '../../domain/services/neighborhood-score.service';
import { GetNeighborhoodScoreHandler } from '../queries/get-neighborhood-score/get-neighborhood-score.handler';
import { GetNeighborhoodScoreQuery } from '../queries/get-neighborhood-score/get-neighborhood-score.query';
const sampleScore: NeighborhoodScoreResult = {
district: 'Quận 1',
city: 'Hồ Chí Minh',
educationScore: 8,
healthcareScore: 7,
transportScore: 9,
shoppingScore: 6,
greeneryScore: 5,
safetyScore: 4,
totalScore: 68.5,
poiCounts: { education: 12, healthcare: 5, transport: 10, shopping: 6, greenery: 3, safety: 2 },
calculatedAt: new Date(),
};
describe('GetNeighborhoodScoreHandler', () => {
let handler: GetNeighborhoodScoreHandler;
let mockService: { [K in keyof INeighborhoodScoreService]: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockService = {
getScore: vi.fn(),
calculateAndSave: vi.fn(),
};
handler = new GetNeighborhoodScoreHandler(mockService as any);
});
it('returns cached score when available', async () => {
mockService.getScore.mockResolvedValue(sampleScore);
const result = await handler.execute(new GetNeighborhoodScoreQuery('Quận 1', 'Hồ Chí Minh'));
expect(result).toEqual(sampleScore);
expect(mockService.getScore).toHaveBeenCalledWith('Quận 1', 'Hồ Chí Minh');
expect(mockService.calculateAndSave).not.toHaveBeenCalled();
});
it('calculates and saves score when no cached score exists', async () => {
mockService.getScore.mockResolvedValue(null);
mockService.calculateAndSave.mockResolvedValue(sampleScore);
const result = await handler.execute(new GetNeighborhoodScoreQuery('Quận 2', 'Hồ Chí Minh'));
expect(result).toEqual(sampleScore);
expect(mockService.getScore).toHaveBeenCalledWith('Quận 2', 'Hồ Chí Minh');
expect(mockService.calculateAndSave).toHaveBeenCalledWith('Quận 2', 'Hồ Chí Minh');
});
});

View File

@@ -0,0 +1,146 @@
import { InternalServerErrorException } from '@nestjs/common';
import { type CacheService, type PrismaService } from '@modules/shared';
import { DomainException } from '@modules/shared';
import { type IAVMService, type ValuationResult } from '../../domain/services/avm-service';
import { ValuationComparisonHandler } from '../queries/valuation-comparison/valuation-comparison.handler';
import { ValuationComparisonQuery } from '../queries/valuation-comparison/valuation-comparison.query';
describe('ValuationComparisonHandler', () => {
let handler: ValuationComparisonHandler;
let mockAvm: { [K in keyof IAVMService]: ReturnType<typeof vi.fn> };
let mockPrisma: { property: { findMany: ReturnType<typeof vi.fn> } };
let mockLogger: { error: ReturnType<typeof vi.fn> };
const makeValuation = (price: string, confidence: number, pricePerM2: number): ValuationResult => ({
estimatedPrice: price,
confidence,
pricePerM2,
comparables: [
{
propertyId: 'comp-1',
address: '123 Test',
district: 'Quận 1',
priceVND: price,
pricePerM2,
areaM2: 70,
propertyType: 'APARTMENT' as const,
distanceMeters: 200,
soldAt: '2026-03-01T00:00:00.000Z',
},
],
modelVersion: 'avm-v1.0',
});
const sampleProperties = [
{ id: 'prop-1', address: '10 Nguyễn Huệ', district: 'Quận 1', areaM2: 80, propertyType: 'APARTMENT' },
{ id: 'prop-2', address: '20 Lê Lợi', district: 'Quận 3', areaM2: 100, propertyType: 'HOUSE' },
];
beforeEach(() => {
mockAvm = {
estimateValue: vi.fn(),
getComparables: vi.fn(),
estimateBatch: vi.fn(),
};
mockPrisma = {
property: { findMany: vi.fn() },
};
mockLogger = { error: vi.fn() };
const mockCache = {
getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()),
} as unknown as CacheService;
handler = new ValuationComparisonHandler(
mockAvm as any,
mockPrisma as unknown as PrismaService,
mockCache,
mockLogger as any,
);
});
it('compares valuations for multiple properties with summary', async () => {
mockPrisma.property.findMany.mockResolvedValue(sampleProperties);
mockAvm.estimateValue
.mockResolvedValueOnce(makeValuation('5000000000', 0.85, 75000000))
.mockResolvedValueOnce(makeValuation('8000000000', 0.90, 80000000));
const query = new ValuationComparisonQuery(['prop-1', 'prop-2']);
const result = await handler.execute(query);
expect(result.properties).toHaveLength(2);
expect(result.properties[0]!.propertyId).toBe('prop-1');
expect(result.properties[0]!.address).toBe('10 Nguyễn Huệ');
expect(result.properties[1]!.propertyId).toBe('prop-2');
// Summary checks
expect(result.summary.highestValue!.propertyId).toBe('prop-2');
expect(result.summary.highestValue!.estimatedPrice).toBe('8000000000');
expect(result.summary.lowestValue!.propertyId).toBe('prop-1');
expect(result.summary.lowestValue!.estimatedPrice).toBe('5000000000');
expect(result.summary.averagePricePerM2).toBe(77500000);
expect(result.summary.averageConfidence).toBe(0.88);
});
it('handles properties where valuation fails gracefully', async () => {
mockPrisma.property.findMany.mockResolvedValue(sampleProperties);
mockAvm.estimateValue
.mockResolvedValueOnce(makeValuation('5000000000', 0.85, 75000000))
.mockRejectedValueOnce(new Error('AI service timeout'));
const query = new ValuationComparisonQuery(['prop-1', 'prop-2']);
const result = await handler.execute(query);
expect(result.properties).toHaveLength(2);
expect(result.properties[0]!.valuation).not.toBeNull();
expect(result.properties[1]!.valuation).toBeNull();
// Summary should only reflect the successful valuation
expect(result.summary.highestValue!.propertyId).toBe('prop-1');
expect(result.summary.lowestValue!.propertyId).toBe('prop-1');
});
it('returns null summary values when all valuations fail', async () => {
mockPrisma.property.findMany.mockResolvedValue(sampleProperties);
mockAvm.estimateValue
.mockRejectedValueOnce(new Error('fail 1'))
.mockRejectedValueOnce(new Error('fail 2'));
const query = new ValuationComparisonQuery(['prop-1', 'prop-2']);
const result = await handler.execute(query);
expect(result.summary.highestValue).toBeNull();
expect(result.summary.lowestValue).toBeNull();
expect(result.summary.averagePricePerM2).toBe(0);
expect(result.summary.averageConfidence).toBe(0);
});
it('handles unknown property IDs with empty details', async () => {
mockPrisma.property.findMany.mockResolvedValue([]);
mockAvm.estimateValue.mockRejectedValue(new Error('Not found'));
const query = new ValuationComparisonQuery(['unknown-1', 'unknown-2']);
const result = await handler.execute(query);
expect(result.properties).toHaveLength(2);
expect(result.properties[0]!.address).toBe('');
expect(result.properties[0]!.district).toBe('');
expect(result.properties[0]!.areaM2).toBe(0);
});
it('re-throws DomainException directly', async () => {
const domainError = new DomainException('VALIDATION_ERROR', 'Too many properties');
mockPrisma.property.findMany.mockRejectedValue(domainError);
const query = new ValuationComparisonQuery(['prop-1', 'prop-2']);
await expect(handler.execute(query)).rejects.toThrow(DomainException);
});
it('wraps unexpected errors in InternalServerErrorException', async () => {
mockPrisma.property.findMany.mockRejectedValue(new Error('Connection refused'));
const query = new ValuationComparisonQuery(['prop-1', 'prop-2']);
await expect(handler.execute(query)).rejects.toThrow(InternalServerErrorException);
expect(mockLogger.error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,111 @@
import { InternalServerErrorException } from '@nestjs/common';
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
import { DomainException } from '@modules/shared';
import { ValuationEntity } from '../../domain/entities/valuation.entity';
import { type IValuationRepository } from '../../domain/repositories/valuation.repository';
import { ValuationHistoryHandler } from '../queries/valuation-history/valuation-history.handler';
import { ValuationHistoryQuery } from '../queries/valuation-history/valuation-history.query';
describe('ValuationHistoryHandler', () => {
let handler: ValuationHistoryHandler;
let mockRepo: { [K in keyof IValuationRepository]: ReturnType<typeof vi.fn> };
let mockLogger: { error: ReturnType<typeof vi.fn> };
const createValuationEntity = (
id: string,
propertyId: string,
estimatedPrice: bigint,
confidence: number,
pricePerM2: number,
modelVersion: string,
createdAt: Date,
): ValuationEntity =>
new ValuationEntity(
id,
{ propertyId, estimatedPrice, confidence, pricePerM2, comparables: [], features: {}, modelVersion },
createdAt,
createdAt,
);
const sampleEntities = [
createValuationEntity('v1', 'prop-1', 5000000000n, 0.85, 75000000, 'avm-v1.0', new Date('2026-04-01')),
createValuationEntity('v2', 'prop-1', 5200000000n, 0.88, 78000000, 'avm-v1.1', new Date('2026-03-01')),
createValuationEntity('v3', 'prop-1', 4800000000n, 0.82, 72000000, 'avm-v1.0', new Date('2026-02-01')),
];
beforeEach(() => {
mockRepo = {
findById: vi.fn(),
findByPropertyId: vi.fn(),
findLatestByPropertyId: vi.fn(),
save: vi.fn(),
};
mockLogger = { error: vi.fn() };
const mockCache = {
getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()),
} as unknown as CacheService;
handler = new ValuationHistoryHandler(
mockRepo as any,
mockCache,
mockLogger as any,
);
});
it('returns valuation history for a property', async () => {
mockRepo.findByPropertyId.mockResolvedValue(sampleEntities);
const query = new ValuationHistoryQuery('prop-1');
const result = await handler.execute(query);
expect(result.propertyId).toBe('prop-1');
expect(result.history).toHaveLength(3);
expect(result.totalRecords).toBe(3);
expect(result.history[0]!.estimatedPrice).toBe('5000000000');
expect(result.history[0]!.confidence).toBe(0.85);
expect(result.history[0]!.modelVersion).toBe('avm-v1.0');
});
it('respects the limit parameter', async () => {
mockRepo.findByPropertyId.mockResolvedValue(sampleEntities);
const query = new ValuationHistoryQuery('prop-1', 2);
const result = await handler.execute(query);
expect(result.history).toHaveLength(2);
expect(result.totalRecords).toBe(3);
});
it('returns empty history when no valuations exist', async () => {
mockRepo.findByPropertyId.mockResolvedValue([]);
const query = new ValuationHistoryQuery('prop-none');
const result = await handler.execute(query);
expect(result.propertyId).toBe('prop-none');
expect(result.history).toHaveLength(0);
expect(result.totalRecords).toBe(0);
});
it('re-throws DomainException directly', async () => {
const domainError = new DomainException('NOT_FOUND', 'Property not found');
mockRepo.findByPropertyId.mockRejectedValue(domainError);
const query = new ValuationHistoryQuery('prop-bad');
await expect(handler.execute(query)).rejects.toThrow(DomainException);
});
it('wraps unexpected errors in InternalServerErrorException', async () => {
mockRepo.findByPropertyId.mockRejectedValue(new Error('DB connection lost'));
const query = new ValuationHistoryQuery('prop-1');
await expect(handler.execute(query)).rejects.toThrow(InternalServerErrorException);
expect(mockLogger.error).toHaveBeenCalled();
});
it('uses default limit of 50', () => {
const query = new ValuationHistoryQuery('prop-1');
expect(query.limit).toBe(50);
});
});

View File

@@ -26,7 +26,7 @@ export class BatchValuationHandler implements IQueryHandler<BatchValuationQuery>
...query.propertyIds.slice().sort(),
);
return this.cache.getOrSet(
return await this.cache.getOrSet(
cacheKey,
async () => {
const items = query.propertyIds.map((propertyId) => ({ propertyId }));

View File

@@ -0,0 +1,141 @@
import { InternalServerErrorException } from '@nestjs/common';
import { type IAiServiceClient } from '../../../../infrastructure/services/ai-service.client';
import { IndustrialValuationHandler } from '../industrial-valuation.handler';
import { IndustrialValuationQuery } from '../industrial-valuation.query';
describe('IndustrialValuationHandler', () => {
let handler: IndustrialValuationHandler;
let mockAiClient: { predictIndustrial: ReturnType<typeof vi.fn> };
let mockLogger: { error: ReturnType<typeof vi.fn> };
const query = new IndustrialValuationQuery(
'Bình Dương',
'south',
0.85,
500,
10,
25,
40,
5,
'factory',
5000,
10,
3,
2000,
0.6,
4,
'general_industrial',
0.7,
3000,
8000000,
0.75,
);
const aiResponse = {
estimated_rent_usd_m2: 5.2,
confidence: 0.65,
rent_range_low_usd_m2: 4.16,
rent_range_high_usd_m2: 6.24,
annual_rent_usd_m2: 62.4,
total_monthly_rent_usd: 26000,
comparables: [
{
park_name: 'VSIP I',
province: 'Bình Dương',
property_type: 'factory',
area_m2: 5000,
rent_usd_m2: 5.2,
similarity_score: 0.85,
},
],
drivers: [
{ feature: 'province_baseline', importance: 0.16 },
{ feature: 'property_type', importance: 0.12 },
],
model_version: 'heuristic-v1',
};
beforeEach(() => {
mockAiClient = { predictIndustrial: vi.fn() };
mockLogger = { error: vi.fn() };
handler = new IndustrialValuationHandler(
mockAiClient as unknown as IAiServiceClient,
mockLogger as any,
);
});
it('calls AI service with correct snake_case parameters', async () => {
mockAiClient.predictIndustrial.mockResolvedValue(aiResponse);
await handler.execute(query);
expect(mockAiClient.predictIndustrial).toHaveBeenCalledWith({
province: 'Bình Dương',
region: 'south',
park_occupancy_rate: 0.85,
park_area_ha: 500,
park_age_years: 10,
distance_to_port_km: 25,
distance_to_airport_km: 40,
distance_to_highway_km: 5,
property_type: 'factory',
area_m2: 5000,
ceiling_height_m: 10,
floor_load_ton_m2: 3,
power_capacity_kva: 2000,
building_coverage: 0.6,
loading_docks: 4,
zoning: 'general_industrial',
industry_demand_index: 0.7,
fdi_province_musd: 3000,
labor_cost_province_vnd: 8000000,
logistics_connectivity_score: 0.75,
});
});
it('maps AI response to camelCase DTO', async () => {
mockAiClient.predictIndustrial.mockResolvedValue(aiResponse);
const result = await handler.execute(query);
expect(result.estimatedRentUsdM2).toBe(5.2);
expect(result.confidence).toBe(0.65);
expect(result.rentRangeLowUsdM2).toBe(4.16);
expect(result.rentRangeHighUsdM2).toBe(6.24);
expect(result.annualRentUsdM2).toBe(62.4);
expect(result.totalMonthlyRentUsd).toBe(26000);
expect(result.modelVersion).toBe('heuristic-v1');
});
it('maps comparable properties to camelCase', async () => {
mockAiClient.predictIndustrial.mockResolvedValue(aiResponse);
const result = await handler.execute(query);
expect(result.comparables).toHaveLength(1);
expect(result.comparables[0]).toEqual({
parkName: 'VSIP I',
province: 'Bình Dương',
propertyType: 'factory',
areaM2: 5000,
rentUsdM2: 5.2,
similarityScore: 0.85,
});
});
it('maps drivers array', async () => {
mockAiClient.predictIndustrial.mockResolvedValue(aiResponse);
const result = await handler.execute(query);
expect(result.drivers).toHaveLength(2);
expect(result.drivers[0]).toEqual({ feature: 'province_baseline', importance: 0.16 });
});
it('throws InternalServerErrorException on AI service failure', async () => {
mockAiClient.predictIndustrial.mockRejectedValue(new Error('AI service down'));
await expect(handler.execute(query)).rejects.toThrow(InternalServerErrorException);
expect(mockLogger.error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,106 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { DomainException, type LoggerService } from '@modules/shared';
import {
AI_SERVICE_CLIENT,
type IAiServiceClient,
type AiIndustrialPredictResponse,
} from '../../../infrastructure/services/ai-service.client';
import { IndustrialValuationQuery } from './industrial-valuation.query';
export interface IndustrialValuationComparable {
parkName: string;
province: string;
propertyType: string;
areaM2: number;
rentUsdM2: number;
similarityScore: number;
}
export interface IndustrialValuationDriver {
feature: string;
importance: number;
}
export interface IndustrialValuationDto {
estimatedRentUsdM2: number;
confidence: number;
rentRangeLowUsdM2: number;
rentRangeHighUsdM2: number;
annualRentUsdM2: number;
totalMonthlyRentUsd: number;
comparables: IndustrialValuationComparable[];
drivers: IndustrialValuationDriver[];
modelVersion: string;
}
function mapResponse(res: AiIndustrialPredictResponse): IndustrialValuationDto {
return {
estimatedRentUsdM2: res.estimated_rent_usd_m2,
confidence: res.confidence,
rentRangeLowUsdM2: res.rent_range_low_usd_m2,
rentRangeHighUsdM2: res.rent_range_high_usd_m2,
annualRentUsdM2: res.annual_rent_usd_m2,
totalMonthlyRentUsd: res.total_monthly_rent_usd,
comparables: res.comparables.map((c) => ({
parkName: c.park_name,
province: c.province,
propertyType: c.property_type,
areaM2: c.area_m2,
rentUsdM2: c.rent_usd_m2,
similarityScore: c.similarity_score,
})),
drivers: res.drivers.map((d) => ({
feature: d.feature,
importance: d.importance,
})),
modelVersion: res.model_version,
};
}
@QueryHandler(IndustrialValuationQuery)
export class IndustrialValuationHandler implements IQueryHandler<IndustrialValuationQuery> {
constructor(
@Inject(AI_SERVICE_CLIENT) private readonly aiClient: IAiServiceClient,
private readonly logger: LoggerService,
) {}
async execute(query: IndustrialValuationQuery): Promise<IndustrialValuationDto> {
try {
const response = await this.aiClient.predictIndustrial({
province: query.province,
region: query.region,
park_occupancy_rate: query.parkOccupancyRate,
park_area_ha: query.parkAreaHa,
park_age_years: query.parkAgeYears,
distance_to_port_km: query.distanceToPortKm,
distance_to_airport_km: query.distanceToAirportKm,
distance_to_highway_km: query.distanceToHighwayKm,
property_type: query.propertyType,
area_m2: query.areaM2,
ceiling_height_m: query.ceilingHeightM,
floor_load_ton_m2: query.floorLoadTonM2,
power_capacity_kva: query.powerCapacityKva,
building_coverage: query.buildingCoverage,
loading_docks: query.loadingDocks,
zoning: query.zoning,
industry_demand_index: query.industryDemandIndex,
fdi_province_musd: query.fdiProvinceMusd,
labor_cost_province_vnd: query.laborCostProvinceVnd,
logistics_connectivity_score: query.logisticsConnectivityScore,
});
return mapResponse(response);
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to estimate industrial rent: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException(
'Không thể ước tính giá thuê khu công nghiệp. Vui lòng thử lại sau.',
);
}
}
}

View File

@@ -0,0 +1,24 @@
export class IndustrialValuationQuery {
constructor(
public readonly province: string,
public readonly region: string,
public readonly parkOccupancyRate: number,
public readonly parkAreaHa: number,
public readonly parkAgeYears: number,
public readonly distanceToPortKm: number,
public readonly distanceToAirportKm: number,
public readonly distanceToHighwayKm: number,
public readonly propertyType: string,
public readonly areaM2: number,
public readonly ceilingHeightM?: number,
public readonly floorLoadTonM2?: number,
public readonly powerCapacityKva?: number,
public readonly buildingCoverage?: number,
public readonly loadingDocks?: number,
public readonly zoning?: string,
public readonly industryDemandIndex?: number,
public readonly fdiProvinceMusd?: number,
public readonly laborCostProvinceVnd?: number,
public readonly logisticsConnectivityScore?: number,
) {}
}

View File

@@ -37,7 +37,7 @@ export class ValuationComparisonHandler implements IQueryHandler<ValuationCompar
...query.propertyIds.slice().sort(),
);
return this.cache.getOrSet(
return await this.cache.getOrSet(
cacheKey,
() => this.buildComparison(query.propertyIds),
CacheTTL.MARKET_DATA,

View File

@@ -31,7 +31,7 @@ export class ValuationHistoryHandler implements IQueryHandler<ValuationHistoryQu
query.limit.toString(),
);
return this.cache.getOrSet(
return await this.cache.getOrSet(
cacheKey,
async () => {
const entities = await this.valuationRepo.findByPropertyId(query.propertyId);

View File

@@ -0,0 +1,216 @@
import {
HttpNeighborhoodScoreService,
NeighborhoodScoreServiceImpl,
PrismaNeighborhoodScoreService,
} from '../services/neighborhood-score.service';
describe('NeighborhoodScoreServiceImpl', () => {
let service: NeighborhoodScoreServiceImpl;
let mockPrisma: {
neighborhoodScore: { findUnique: ReturnType<typeof vi.fn>; upsert: ReturnType<typeof vi.fn> };
pOI: { count: ReturnType<typeof vi.fn> };
};
let mockLogger: { log: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockPrisma = {
neighborhoodScore: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
pOI: { count: vi.fn() },
};
mockLogger = { log: vi.fn() };
service = new NeighborhoodScoreServiceImpl(mockPrisma as any, mockLogger as any);
});
describe('getScore', () => {
it('returns existing score from database', async () => {
const stored = {
district: 'Quận 1',
city: 'Hồ Chí Minh',
educationScore: 8,
healthcareScore: 7,
transportScore: 9,
shoppingScore: 6,
greeneryScore: 5,
safetyScore: 4,
totalScore: 68.5,
poiCounts: { education: 12, healthcare: 5 },
calculatedAt: new Date(),
};
mockPrisma.neighborhoodScore.findUnique.mockResolvedValue(stored);
const result = await service.getScore('Quận 1', 'Hồ Chí Minh');
expect(result).not.toBeNull();
expect(result!.district).toBe('Quận 1');
expect(result!.totalScore).toBe(68.5);
expect(result!.poiCounts).toEqual({ education: 12, healthcare: 5 });
});
it('returns null when no score exists', async () => {
mockPrisma.neighborhoodScore.findUnique.mockResolvedValue(null);
const result = await service.getScore('Quận 99', 'Hồ Chí Minh');
expect(result).toBeNull();
});
});
describe('calculateAndSave', () => {
it('calculates scores from POI counts and upserts', async () => {
// Simulate POI counts: education=15 (max), healthcare=4 (50%), transport=6 (50%),
// shopping=5 (50%), greenery=3 (50%), safety=2 (50%)
const poiCountsByCategory = [15, 4, 6, 5, 3, 2];
let callIndex = 0;
mockPrisma.pOI.count.mockImplementation(() => {
return Promise.resolve(poiCountsByCategory[callIndex++]!);
});
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => {
return Promise.resolve(create);
});
const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
// education: 15/15 * 10 = 10 → 10 * 20/10 = 20
// healthcare: 4/8 * 10 = 5 → 5 * 20/10 = 10
// transport: 6/12 * 10 = 5 → 5 * 20/10 = 10
// shopping: 5/10 * 10 = 5 → 5 * 15/10 = 7.5
// greenery: 3/6 * 10 = 5 → 5 * 15/10 = 7.5
// safety: 2/4 * 10 = 5 → 5 * 10/10 = 5
// total = 20 + 10 + 10 + 7.5 + 7.5 + 5 = 60
expect(result.educationScore).toBe(10);
expect(result.healthcareScore).toBe(5);
expect(result.totalScore).toBe(60);
expect(mockPrisma.neighborhoodScore.upsert).toHaveBeenCalledTimes(1);
});
it('caps category scores at 10', async () => {
// All categories have way more POIs than max
mockPrisma.pOI.count.mockResolvedValue(100);
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => {
return Promise.resolve(create);
});
const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
// All scores capped at 10 → total = sum of weights = 100
expect(result.educationScore).toBe(10);
expect(result.healthcareScore).toBe(10);
expect(result.transportScore).toBe(10);
expect(result.shoppingScore).toBe(10);
expect(result.greeneryScore).toBe(10);
expect(result.safetyScore).toBe(10);
expect(result.totalScore).toBe(100);
});
it('returns 0 scores when no POIs exist', async () => {
mockPrisma.pOI.count.mockResolvedValue(0);
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => {
return Promise.resolve(create);
});
const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
expect(result.educationScore).toBe(0);
expect(result.totalScore).toBe(0);
});
it('logs the calculated score', async () => {
mockPrisma.pOI.count.mockResolvedValue(5);
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => {
return Promise.resolve(create);
});
await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
expect(mockLogger.log).toHaveBeenCalledWith(
expect.stringContaining('Quận 1'),
'NeighborhoodScoreService',
);
});
});
});
describe('HttpNeighborhoodScoreService', () => {
let httpService: HttpNeighborhoodScoreService;
let prismaFallback: PrismaNeighborhoodScoreService;
let mockPrisma: {
neighborhoodScore: { findUnique: ReturnType<typeof vi.fn>; upsert: ReturnType<typeof vi.fn> };
pOI: { count: ReturnType<typeof vi.fn> };
};
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
let mockAiClient: { scoreNeighborhood: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockPrisma = {
neighborhoodScore: { findUnique: vi.fn(), upsert: vi.fn() },
pOI: { count: vi.fn() },
};
mockLogger = { log: vi.fn(), warn: vi.fn() };
mockAiClient = { scoreNeighborhood: vi.fn() };
prismaFallback = new PrismaNeighborhoodScoreService(
mockPrisma as any,
mockLogger as any,
);
httpService = new HttpNeighborhoodScoreService(
mockPrisma as any,
mockLogger as any,
mockAiClient as any,
prismaFallback,
);
});
it('persists AI service response when scoreNeighborhood succeeds', async () => {
mockPrisma.pOI.count.mockResolvedValue(6);
mockAiClient.scoreNeighborhood.mockResolvedValue({
district: 'Quận 1',
city: 'Hồ Chí Minh',
education_score: 8.5,
healthcare_score: 7,
transport_score: 9,
shopping_score: 6,
greenery_score: 5.5,
safety_score: 4,
total_score: 71.2,
poi_counts: { education: 6, healthcare: 6, transport: 6, shopping: 6, greenery: 6, safety: 6 },
algorithm_version: 'neighborhood-heuristic-v1',
});
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => Promise.resolve(create));
const result = await httpService.calculateAndSave('Quận 1', 'Hồ Chí Minh');
expect(mockAiClient.scoreNeighborhood).toHaveBeenCalledOnce();
expect(result.totalScore).toBe(71.2);
expect(result.educationScore).toBe(8.5);
expect(mockPrisma.neighborhoodScore.upsert).toHaveBeenCalledOnce();
});
it('falls back to prisma scoring when AI service throws', async () => {
mockPrisma.pOI.count.mockResolvedValue(0);
mockAiClient.scoreNeighborhood.mockRejectedValue(new Error('AI service down'));
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => Promise.resolve(create));
const result = await httpService.calculateAndSave('Quận 7', 'Hồ Chí Minh');
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('falling back to prisma scoring'),
'NeighborhoodScoreService',
);
expect(result.totalScore).toBe(0);
expect(mockPrisma.neighborhoodScore.upsert).toHaveBeenCalledOnce();
});
it('delegates getScore to prisma fallback', async () => {
mockPrisma.neighborhoodScore.findUnique.mockResolvedValue(null);
const result = await httpService.getScore('Quận 99', 'Hồ Chí Minh');
expect(result).toBeNull();
expect(mockPrisma.neighborhoodScore.findUnique).toHaveBeenCalledOnce();
expect(mockAiClient.scoreNeighborhood).not.toHaveBeenCalled();
});
});

View File

@@ -23,6 +23,55 @@ export interface AiPredictResponse {
price_range_high: number;
}
export interface AiIndustrialPredictRequest {
province: string;
region: string;
park_occupancy_rate: number;
park_area_ha: number;
park_age_years: number;
distance_to_port_km: number;
distance_to_airport_km: number;
distance_to_highway_km: number;
property_type: string;
area_m2: number;
ceiling_height_m?: number;
floor_load_ton_m2?: number;
power_capacity_kva?: number;
building_coverage?: number;
loading_docks?: number;
zoning?: string;
industry_demand_index?: number;
fdi_province_musd?: number;
labor_cost_province_vnd?: number;
logistics_connectivity_score?: number;
}
export interface AiIndustrialComparable {
park_name: string;
province: string;
property_type: string;
area_m2: number;
rent_usd_m2: number;
similarity_score: number;
}
export interface AiIndustrialFeatureImportance {
feature: string;
importance: number;
}
export interface AiIndustrialPredictResponse {
estimated_rent_usd_m2: number;
confidence: number;
rent_range_low_usd_m2: number;
rent_range_high_usd_m2: number;
annual_rent_usd_m2: number;
total_monthly_rent_usd: number;
comparables: AiIndustrialComparable[];
drivers: AiIndustrialFeatureImportance[];
model_version: string;
}
export interface AiModerationRequest {
text: string;
context?: string;
@@ -42,11 +91,42 @@ export interface AiModerationResponse {
cleaned_text: string | null;
}
export interface AiNeighborhoodPOICounts {
education: number;
healthcare: number;
transport: number;
shopping: number;
greenery: number;
safety: number;
}
export interface AiNeighborhoodScoreRequest {
district: string;
city: string;
poi_counts: AiNeighborhoodPOICounts;
}
export interface AiNeighborhoodScoreResponse {
district: string;
city: string;
education_score: number;
healthcare_score: number;
transport_score: number;
shopping_score: number;
greenery_score: number;
safety_score: number;
total_score: number;
poi_counts: Record<string, number>;
algorithm_version: string;
}
export const AI_SERVICE_CLIENT = Symbol('AI_SERVICE_CLIENT');
export interface IAiServiceClient {
predict(req: AiPredictRequest): Promise<AiPredictResponse>;
predictIndustrial(req: AiIndustrialPredictRequest): Promise<AiIndustrialPredictResponse>;
moderate(req: AiModerationRequest): Promise<AiModerationResponse>;
scoreNeighborhood(req: AiNeighborhoodScoreRequest): Promise<AiNeighborhoodScoreResponse>;
isAvailable(): Promise<boolean>;
}
@@ -66,10 +146,20 @@ export class AiServiceClient implements IAiServiceClient {
return this.post<AiPredictResponse>('/avm/predict', req);
}
async predictIndustrial(req: AiIndustrialPredictRequest): Promise<AiIndustrialPredictResponse> {
return this.post<AiIndustrialPredictResponse>('/avm/industrial/predict', req);
}
async moderate(req: AiModerationRequest): Promise<AiModerationResponse> {
return this.post<AiModerationResponse>('/moderation/check', req);
}
async scoreNeighborhood(
req: AiNeighborhoodScoreRequest,
): Promise<AiNeighborhoodScoreResponse> {
return this.post<AiNeighborhoodScoreResponse>('/neighborhood/score', req);
}
async isAvailable(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/health`, {

View File

@@ -0,0 +1,297 @@
import { Injectable } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { type PrismaService, type LoggerService } from '@modules/shared';
@Injectable()
export class AvmRetrainCronService {
private readonly aiServiceUrl: string;
private readonly aiServiceApiKey: string;
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {
this.aiServiceUrl = process.env['AI_SERVICE_URL'] ?? 'http://localhost:8000';
this.aiServiceApiKey = process.env['AI_SERVICE_API_KEY'] ?? '';
}
/**
* Weekly retrain — every Sunday at 3 AM.
*
* 1. Export training data from database to the AI service
* 2. Trigger ensemble retraining via POST /avm/v2/train
* 3. Log results (version, metrics)
*/
@Cron('0 3 * * 0', { name: 'avm-v2-weekly-retrain' })
async weeklyRetrain(): Promise<void> {
this.logger.log('Starting weekly AVM v2 retrain...', 'AvmRetrainCronService');
try {
// Step 1: Export training data
const trainingData = await this.exportTrainingData();
if (trainingData.length < 50) {
this.logger.warn(
`Insufficient training data (${trainingData.length} rows). Skipping retrain.`,
'AvmRetrainCronService',
);
return;
}
// Step 2: Upload training data to AI service
await this.uploadTrainingData(trainingData);
// Step 3: Trigger retraining
const result = await this.triggerRetrain();
this.logger.log(
`AVM v2 retrain completed: version=${result.model_version}, ` +
`MAPE=${result.metrics?.mape ?? 'N/A'}%, ` +
`samples=${result.training_samples}`,
'AvmRetrainCronService',
);
} catch (err) {
this.logger.error(
`AVM v2 weekly retrain failed: ${(err as Error).message}`,
undefined,
'AvmRetrainCronService',
);
}
}
/**
* Export property + listing + market data as training rows.
*
* Each row maps to the feature columns expected by the Python
* AVM v2 training pipeline (see avm_v2_service._prepare_training_data).
*/
async exportTrainingData(): Promise<TrainingRow[]> {
const rows = await this.prisma.$queryRaw<RawTrainingRow[]>`
WITH market AS (
SELECT
mi.district,
mi.city,
mi."avgPriceM2" AS avg_price_m2,
mi."totalListings" AS listing_density,
COALESCE(mi."absorptionRate", 0) AS absorption_rate,
mi."daysOnMarket" AS dom_avg,
COALESCE(mi."yoyChange", 0) AS yoy_change
FROM "MarketIndex" mi
WHERE mi.period = (
SELECT MAX(period) FROM "MarketIndex"
)
)
SELECT
p."propertyType"::text AS property_type,
p."areaM2" AS area_m2,
COALESCE(p.bedrooms, 2) AS rooms,
COALESCE(p.floor, 0) AS floor_level,
COALESCE(p."totalFloors", p.floors, 0) AS total_floors,
COALESCE(p.direction::text, 'unknown') AS direction,
CASE
WHEN p."totalFloors" > 0 AND p."areaM2" > 0
THEN (p."totalFloors"::float * p."areaM2") / NULLIF(p."areaM2", 0)
ELSE 1.0
END AS floor_ratio,
CASE
WHEN p."yearBuilt" IS NOT NULL
THEN EXTRACT(YEAR FROM NOW())::int - p."yearBuilt"
ELSE 5
END AS building_age_years,
CASE WHEN p.amenities::text ILIKE '%elevator%' THEN 1.0 ELSE 0.0 END AS has_elevator,
CASE WHEN p.amenities::text ILIKE '%parking%' THEN 1.0 ELSE 0.0 END AS has_parking,
CASE WHEN p.amenities::text ILIKE '%pool%' THEN 1.0 ELSE 0.0 END AS has_pool,
CASE
WHEN p."legalStatus" IN ('so_do', 'so_hong', 'SO_DO', 'SO_HONG') THEN 1.0
ELSE 0.0
END AS has_legal_paper,
0.5 AS developer_reputation,
0.5 AS neighborhood_score,
COALESCE(
ST_Distance(
p.location::geography,
ST_SetSRID(ST_MakePoint(106.6297, 10.8231), 4326)::geography
) / 1000.0,
10.0
) AS distance_to_cbd_km,
COALESCE(p."metroDistanceM" / 1000.0, 5.0) AS distance_to_metro_km,
5.0 AS distance_to_school_km,
3.0 AS distance_to_hospital_km,
2.0 AS distance_to_park_km,
4.0 AS distance_to_mall_km,
0.1 AS flood_zone_risk,
COALESCE(m.avg_price_m2, 0) AS avg_price_district_3m_vnd_m2,
COALESCE(m.listing_density, 0) AS listing_density,
COALESCE(m.absorption_rate, 0) AS absorption_rate,
COALESCE(m.dom_avg, 30) AS dom_avg,
0.0 AS price_momentum_30d,
COALESCE(m.yoy_change, 0) AS yoy_change,
0.5 AS renovation_score,
0.5 AS view_quality,
0.5 AS interior_quality,
0.3 AS noise_level,
0.5 AS natural_light,
EXTRACT(MONTH FROM l."publishedAt")::int AS month,
p.district AS district,
l."priceVND"::float AS price_vnd
FROM "Listing" l
JOIN "Property" p ON l."propertyId" = p.id
LEFT JOIN market m ON m.district = p.district AND m.city = p.city
WHERE l.status IN ('ACTIVE', 'SOLD', 'RENTED')
AND l."priceVND" > 100000000
AND l."publishedAt" IS NOT NULL
AND p."areaM2" > 0
ORDER BY l."publishedAt" DESC
LIMIT 50000
`;
return rows.map((r) => ({
property_type: String(r.property_type).toLowerCase(),
area_m2: Number(r.area_m2),
rooms: Number(r.rooms),
floor_level: Number(r.floor_level),
total_floors: Number(r.total_floors),
direction: String(r.direction).toLowerCase(),
floor_ratio: Number(r.floor_ratio),
building_age_years: Number(r.building_age_years),
has_elevator: Number(r.has_elevator),
has_parking: Number(r.has_parking),
has_pool: Number(r.has_pool),
has_legal_paper: Number(r.has_legal_paper),
developer_reputation: Number(r.developer_reputation),
neighborhood_score: Number(r.neighborhood_score),
distance_to_cbd_km: Number(r.distance_to_cbd_km),
distance_to_metro_km: Number(r.distance_to_metro_km),
distance_to_school_km: Number(r.distance_to_school_km),
distance_to_hospital_km: Number(r.distance_to_hospital_km),
distance_to_park_km: Number(r.distance_to_park_km),
distance_to_mall_km: Number(r.distance_to_mall_km),
flood_zone_risk: Number(r.flood_zone_risk),
avg_price_district_3m_vnd_m2: Number(r.avg_price_district_3m_vnd_m2),
listing_density: Number(r.listing_density),
absorption_rate: Number(r.absorption_rate),
dom_avg: Number(r.dom_avg),
price_momentum_30d: Number(r.price_momentum_30d),
yoy_change: Number(r.yoy_change),
renovation_score: Number(r.renovation_score),
view_quality: Number(r.view_quality),
interior_quality: Number(r.interior_quality),
noise_level: Number(r.noise_level),
natural_light: Number(r.natural_light),
month: Number(r.month),
district: String(r.district),
price_vnd: Number(r.price_vnd),
}));
}
private async uploadTrainingData(rows: TrainingRow[]): Promise<void> {
const headers = Object.keys(rows[0]!);
const csvLines = [headers.join(',')];
for (const row of rows) {
csvLines.push(headers.map((h) => String(row[h as keyof TrainingRow])).join(','));
}
const csv = csvLines.join('\n');
const url = `${this.aiServiceUrl}/avm/v2/upload-training-data`;
const reqHeaders: Record<string, string> = { 'Content-Type': 'text/csv' };
if (this.aiServiceApiKey) {
reqHeaders['X-API-Key'] = this.aiServiceApiKey;
}
const response = await fetch(url, {
method: 'POST',
headers: reqHeaders,
body: csv,
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`Training data upload failed (${response.status}): ${text}`);
}
this.logger.log(
`Uploaded ${rows.length} training rows to AI service`,
'AvmRetrainCronService',
);
}
private async triggerRetrain(): Promise<RetrainResult> {
const url = `${this.aiServiceUrl}/avm/v2/train`;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.aiServiceApiKey) {
headers['X-API-Key'] = this.aiServiceApiKey;
}
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({
optuna_trials: 50,
test_size: 0.15,
val_size: 0.15,
}),
signal: AbortSignal.timeout(600_000), // 10 min — training can take a while
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`Retrain request failed (${response.status}): ${text}`);
}
return response.json() as Promise<RetrainResult>;
}
}
interface RawTrainingRow {
property_type: string;
area_m2: number;
rooms: number;
floor_level: number;
total_floors: number;
direction: string;
floor_ratio: number;
building_age_years: number;
has_elevator: number;
has_parking: number;
has_pool: number;
has_legal_paper: number;
developer_reputation: number;
neighborhood_score: number;
distance_to_cbd_km: number;
distance_to_metro_km: number;
distance_to_school_km: number;
distance_to_hospital_km: number;
distance_to_park_km: number;
distance_to_mall_km: number;
flood_zone_risk: number;
avg_price_district_3m_vnd_m2: number;
listing_density: number;
absorption_rate: number;
dom_avg: number;
price_momentum_30d: number;
yoy_change: number;
renovation_score: number;
view_quality: number;
interior_quality: number;
noise_level: number;
natural_light: number;
month: number;
district: string;
price_vnd: number;
}
interface TrainingRow extends RawTrainingRow {}
interface RetrainResult {
model_version: string;
metrics: {
mae: number;
mape: number;
rmse: number;
r2: number;
};
training_samples: number;
validation_samples: number;
test_samples: number;
best_params: Record<string, unknown>;
}

View File

@@ -1,13 +1,20 @@
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { POIType } from '@prisma/client';
import { type PrismaService, type LoggerService } from '@modules/shared';
import {
type INeighborhoodScoreService,
type NeighborhoodScoreResult,
} from '../../domain/services/neighborhood-score.service';
import {
AI_SERVICE_CLIENT,
type AiNeighborhoodPOICounts,
type IAiServiceClient,
} from './ai-service.client';
/**
* Scoring weights for each POI category.
* Sum = 100 (total score is 0100 weighted average).
* Mirrors the Python heuristic in libs/ai-services/app/services/neighborhood_service.py.
*/
const CATEGORY_WEIGHTS = {
education: 20,
@@ -16,20 +23,20 @@ const CATEGORY_WEIGHTS = {
shopping: 15,
greenery: 15,
safety: 10,
};
} as const;
/** POI types grouped by scoring category. */
const CATEGORY_POI_TYPES: Record<string, string[]> = {
education: ['SCHOOL', 'UNIVERSITY'],
healthcare: ['HOSPITAL', 'CLINIC'],
transport: ['METRO_STATION', 'BUS_STOP'],
shopping: ['MALL', 'MARKET', 'SUPERMARKET'],
greenery: ['PARK'],
safety: ['POLICE_STATION', 'FIRE_STATION'],
const CATEGORY_POI_TYPES: Record<keyof typeof CATEGORY_WEIGHTS, POIType[]> = {
education: [POIType.SCHOOL, POIType.UNIVERSITY],
healthcare: [POIType.HOSPITAL, POIType.CLINIC],
transport: [POIType.METRO_STATION, POIType.BUS_STOP],
shopping: [POIType.MALL, POIType.MARKET, POIType.SUPERMARKET],
greenery: [POIType.PARK],
safety: [POIType.POLICE_STATION, POIType.FIRE_STATION],
};
/** Max count per category that yields a 10/10 score. */
const MAX_COUNTS: Record<string, number> = {
const MAX_COUNTS: Record<keyof typeof CATEGORY_WEIGHTS, number> = {
education: 15,
healthcare: 8,
transport: 12,
@@ -38,8 +45,11 @@ const MAX_COUNTS: Record<string, number> = {
safety: 4,
};
type CategoryKey = keyof typeof CATEGORY_WEIGHTS;
const CATEGORY_KEYS = Object.keys(CATEGORY_WEIGHTS) as CategoryKey[];
@Injectable()
export class NeighborhoodScoreServiceImpl implements INeighborhoodScoreService {
export class PrismaNeighborhoodScoreService implements INeighborhoodScoreService {
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
@@ -52,91 +62,179 @@ export class NeighborhoodScoreServiceImpl implements INeighborhoodScoreService {
if (!existing) return null;
return {
district: existing.district,
city: existing.city,
educationScore: existing.educationScore,
healthcareScore: existing.healthcareScore,
transportScore: existing.transportScore,
shoppingScore: existing.shoppingScore,
greeneryScore: existing.greeneryScore,
safetyScore: existing.safetyScore,
totalScore: existing.totalScore,
poiCounts: existing.poiCounts as Record<string, number>,
calculatedAt: existing.calculatedAt,
};
return mapRecord(existing);
}
async calculateAndSave(district: string, city: string): Promise<NeighborhoodScoreResult> {
// Count POIs per category for this district
const poiCounts: Record<string, number> = {};
const categoryScores: Record<string, number> = {};
const counts = await countPOIs(this.prisma, district, city);
const subScores = scoreFromCounts(counts);
const totalScore = weightedTotal(subScores);
for (const [category, poiTypes] of Object.entries(CATEGORY_POI_TYPES)) {
const count = await this.prisma.pOI.count({
const result = await upsertScore(this.prisma, district, city, subScores, totalScore, counts);
this.logger.log(
`Neighborhood score (prisma) calculated: ${district}, ${city} → total=${result.totalScore}`,
'NeighborhoodScoreService',
);
return mapRecord(result);
}
}
/**
* Calls the Python AI service to compute scores; falls back to local Prisma scoring
* when the service is unavailable or the call times out. Persists to NeighborhoodScore.
*/
@Injectable()
export class HttpNeighborhoodScoreService implements INeighborhoodScoreService {
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
@Inject(AI_SERVICE_CLIENT) private readonly aiClient: IAiServiceClient,
private readonly fallback: PrismaNeighborhoodScoreService,
) {}
async getScore(district: string, city: string): Promise<NeighborhoodScoreResult | null> {
return this.fallback.getScore(district, city);
}
async calculateAndSave(district: string, city: string): Promise<NeighborhoodScoreResult> {
const counts = await countPOIs(this.prisma, district, city);
try {
const aiResult = await this.aiClient.scoreNeighborhood({
district,
city,
poi_counts: counts,
});
const subScores: Record<CategoryKey, number> = {
education: aiResult.education_score,
healthcare: aiResult.healthcare_score,
transport: aiResult.transport_score,
shopping: aiResult.shopping_score,
greenery: aiResult.greenery_score,
safety: aiResult.safety_score,
};
const result = await upsertScore(
this.prisma,
district,
city,
subScores,
aiResult.total_score,
counts,
);
this.logger.log(
`Neighborhood score (ai=${aiResult.algorithm_version}): ${district}, ${city} → total=${result.totalScore}`,
'NeighborhoodScoreService',
);
return mapRecord(result);
} catch (err) {
this.logger.warn(
`AI neighborhood score unavailable, falling back to prisma scoring: ${(err as Error).message}`,
'NeighborhoodScoreService',
);
return this.fallback.calculateAndSave(district, city);
}
}
}
async function countPOIs(
prisma: PrismaService,
district: string,
city: string,
): Promise<AiNeighborhoodPOICounts> {
const entries = await Promise.all(
CATEGORY_KEYS.map(async (cat) => {
const count = await prisma.pOI.count({
where: {
district,
city,
type: { in: poiTypes as any },
type: { in: CATEGORY_POI_TYPES[cat] },
},
});
return [cat, count] as const;
}),
);
poiCounts[category] = count;
// Score 010: linear scale capped at MAX_COUNTS
const maxCount = MAX_COUNTS[category]!;
categoryScores[category] = Math.min(10, (count / maxCount) * 10);
}
// Weighted total score (0100)
const totalScore = Object.entries(CATEGORY_WEIGHTS).reduce((sum, [cat, weight]) => {
return sum + (categoryScores[cat]! * weight) / 10;
}, 0);
const result = await this.prisma.neighborhoodScore.upsert({
where: { district_city: { district, city } },
create: {
district,
city,
educationScore: categoryScores['education']!,
healthcareScore: categoryScores['healthcare']!,
transportScore: categoryScores['transport']!,
shoppingScore: categoryScores['shopping']!,
greeneryScore: categoryScores['greenery']!,
safetyScore: categoryScores['safety']!,
totalScore: Math.round(totalScore * 10) / 10,
poiCounts,
calculatedAt: new Date(),
},
update: {
educationScore: categoryScores['education']!,
healthcareScore: categoryScores['healthcare']!,
transportScore: categoryScores['transport']!,
shoppingScore: categoryScores['shopping']!,
greeneryScore: categoryScores['greenery']!,
safetyScore: categoryScores['safety']!,
totalScore: Math.round(totalScore * 10) / 10,
poiCounts,
calculatedAt: new Date(),
},
});
this.logger.log(
`Neighborhood score calculated: ${district}, ${city} → total=${result.totalScore}`,
'NeighborhoodScoreService',
);
return {
district: result.district,
city: result.city,
educationScore: result.educationScore,
healthcareScore: result.healthcareScore,
transportScore: result.transportScore,
shoppingScore: result.shoppingScore,
greeneryScore: result.greeneryScore,
safetyScore: result.safetyScore,
totalScore: result.totalScore,
poiCounts: result.poiCounts as Record<string, number>,
calculatedAt: result.calculatedAt,
};
}
return Object.fromEntries(entries) as unknown as AiNeighborhoodPOICounts;
}
function scoreFromCounts(counts: AiNeighborhoodPOICounts): Record<CategoryKey, number> {
return Object.fromEntries(
CATEGORY_KEYS.map((cat) => {
const raw = counts[cat] ?? 0;
const max = MAX_COUNTS[cat];
return [cat, Math.min(10, (raw / max) * 10)];
}),
) as Record<CategoryKey, number>;
}
function weightedTotal(subScores: Record<CategoryKey, number>): number {
const sum = CATEGORY_KEYS.reduce(
(acc, cat) => acc + (subScores[cat] * CATEGORY_WEIGHTS[cat]) / 10,
0,
);
return Math.round(sum * 10) / 10;
}
async function upsertScore(
prisma: PrismaService,
district: string,
city: string,
subScores: Record<CategoryKey, number>,
totalScore: number,
counts: AiNeighborhoodPOICounts,
) {
const calculatedAt = new Date();
const data = {
educationScore: subScores.education,
healthcareScore: subScores.healthcare,
transportScore: subScores.transport,
shoppingScore: subScores.shopping,
greeneryScore: subScores.greenery,
safetyScore: subScores.safety,
totalScore,
poiCounts: counts as unknown as Record<string, number>,
calculatedAt,
};
return prisma.neighborhoodScore.upsert({
where: { district_city: { district, city } },
create: { district, city, ...data },
update: data,
});
}
function mapRecord(record: {
district: string;
city: string;
educationScore: number;
healthcareScore: number;
transportScore: number;
shoppingScore: number;
greeneryScore: number;
safetyScore: number;
totalScore: number;
poiCounts: unknown;
calculatedAt: Date;
}): NeighborhoodScoreResult {
return {
district: record.district,
city: record.city,
educationScore: record.educationScore,
healthcareScore: record.healthcareScore,
transportScore: record.transportScore,
shoppingScore: record.shoppingScore,
greeneryScore: record.greeneryScore,
safetyScore: record.safetyScore,
totalScore: record.totalScore,
poiCounts: record.poiCounts as Record<string, number>,
calculatedAt: record.calculatedAt,
};
}
/**
* @deprecated Use HttpNeighborhoodScoreService (binds AI proxy + prisma fallback).
* Kept exported for backward compatibility with callers/tests.
*/
export { PrismaNeighborhoodScoreService as NeighborhoodScoreServiceImpl };

View File

@@ -1,8 +1,12 @@
import { type QueryBus } from '@nestjs/cqrs';
import { BatchValuationQuery } from '../../application/queries/batch-valuation/batch-valuation.query';
import { GetDistrictStatsQuery } from '../../application/queries/get-district-stats/get-district-stats.query';
import { GetHeatmapQuery } from '../../application/queries/get-heatmap/get-heatmap.query';
import { GetMarketReportQuery } from '../../application/queries/get-market-report/get-market-report.query';
import { GetPriceTrendQuery } from '../../application/queries/get-price-trend/get-price-trend.query';
import { GetValuationQuery } from '../../application/queries/get-valuation/get-valuation.query';
import { ValuationComparisonQuery } from '../../application/queries/valuation-comparison/valuation-comparison.query';
import { ValuationHistoryQuery } from '../../application/queries/valuation-history/valuation-history.query';
import { AnalyticsController } from '../controllers/analytics.controller';
describe('AnalyticsController', () => {
@@ -76,4 +80,80 @@ describe('AnalyticsController', () => {
);
expect(result).toBe(expected);
});
it('getValuation executes GetValuationQuery with correct params', async () => {
const expected = { estimatedPrice: '5000000000', confidence: 0.85 };
mockQueryBus.execute.mockResolvedValue(expected);
const result = await controller.getValuation({
propertyId: 'prop-123',
latitude: undefined,
longitude: undefined,
areaM2: undefined,
propertyType: undefined,
} as any);
expect(mockQueryBus.execute).toHaveBeenCalledWith(
new GetValuationQuery('prop-123', undefined, undefined, undefined, undefined),
);
expect(result).toBe(expected);
});
it('batchValuation executes BatchValuationQuery with correct params', async () => {
const expected = [
{ propertyId: 'prop-1', valuation: { estimatedPrice: '5000000000' } },
{ propertyId: 'prop-2', valuation: { estimatedPrice: '6000000000' } },
];
mockQueryBus.execute.mockResolvedValue(expected);
const result = await controller.batchValuation({
propertyIds: ['prop-1', 'prop-2'],
} as any);
expect(mockQueryBus.execute).toHaveBeenCalledWith(
new BatchValuationQuery(['prop-1', 'prop-2']),
);
expect(result).toBe(expected);
});
it('getValuationHistory executes ValuationHistoryQuery with correct params', async () => {
const expected = { propertyId: 'prop-1', history: [], totalRecords: 0 };
mockQueryBus.execute.mockResolvedValue(expected);
const result = await controller.getValuationHistory('prop-1', { limit: 25 } as any);
expect(mockQueryBus.execute).toHaveBeenCalledWith(
new ValuationHistoryQuery('prop-1', 25),
);
expect(result).toBe(expected);
});
it('getValuationHistory defaults limit to 50', async () => {
const expected = { propertyId: 'prop-1', history: [], totalRecords: 0 };
mockQueryBus.execute.mockResolvedValue(expected);
const result = await controller.getValuationHistory('prop-1', {} as any);
expect(mockQueryBus.execute).toHaveBeenCalledWith(
new ValuationHistoryQuery('prop-1', 50),
);
expect(result).toBe(expected);
});
it('compareValuations executes ValuationComparisonQuery with correct params', async () => {
const expected = {
properties: [],
summary: { highestValue: null, lowestValue: null, averagePricePerM2: 0, averageConfidence: 0 },
};
mockQueryBus.execute.mockResolvedValue(expected);
const result = await controller.compareValuations({
propertyIds: ['prop-1', 'prop-2', 'prop-3'],
} as any);
expect(mockQueryBus.execute).toHaveBeenCalledWith(
new ValuationComparisonQuery(['prop-1', 'prop-2', 'prop-3']),
);
expect(result).toBe(expected);
});
});

View File

@@ -0,0 +1,187 @@
import { type QueryBus } from '@nestjs/cqrs';
import { BatchValuationQuery } from '../../application/queries/batch-valuation/batch-valuation.query';
import { IndustrialValuationQuery } from '../../application/queries/industrial-valuation/industrial-valuation.query';
import { ValuationComparisonQuery } from '../../application/queries/valuation-comparison/valuation-comparison.query';
import { ValuationHistoryQuery } from '../../application/queries/valuation-history/valuation-history.query';
import { AvmController } from '../controllers/avm.controller';
describe('AvmController', () => {
let controller: AvmController;
let mockQueryBus: { execute: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockQueryBus = { execute: vi.fn() };
controller = new AvmController(mockQueryBus as unknown as QueryBus);
});
describe('POST /avm/batch', () => {
it('dispatches BatchValuationQuery with property IDs', async () => {
const expected = {
results: [
{ propertyId: 'prop-1', valuation: { estimatedPrice: '5000000000' } },
{ propertyId: 'prop-2', valuation: { estimatedPrice: '6000000000' } },
],
};
mockQueryBus.execute.mockResolvedValue(expected);
const result = await controller.batchValuation({
propertyIds: ['prop-1', 'prop-2'],
} as any);
expect(mockQueryBus.execute).toHaveBeenCalledWith(
new BatchValuationQuery(['prop-1', 'prop-2']),
);
expect(result).toBe(expected);
});
});
describe('GET /avm/history/:propertyId', () => {
it('dispatches ValuationHistoryQuery with propertyId and limit', async () => {
const expected = { propertyId: 'prop-1', history: [], totalRecords: 0 };
mockQueryBus.execute.mockResolvedValue(expected);
const result = await controller.getHistory('prop-1', { limit: 25 } as any);
expect(mockQueryBus.execute).toHaveBeenCalledWith(
new ValuationHistoryQuery('prop-1', 25),
);
expect(result).toBe(expected);
});
it('defaults limit to 50 when not provided', async () => {
const expected = { propertyId: 'prop-1', history: [], totalRecords: 0 };
mockQueryBus.execute.mockResolvedValue(expected);
const result = await controller.getHistory('prop-1', {} as any);
expect(mockQueryBus.execute).toHaveBeenCalledWith(
new ValuationHistoryQuery('prop-1', 50),
);
expect(result).toBe(expected);
});
});
describe('GET /avm/compare', () => {
it('dispatches ValuationComparisonQuery with parsed IDs', async () => {
const expected = {
properties: [],
summary: { highestValue: null, lowestValue: null, averagePricePerM2: 0, averageConfidence: 0 },
};
mockQueryBus.execute.mockResolvedValue(expected);
const result = await controller.compare({
ids: ['prop-1', 'prop-2', 'prop-3'],
} as any);
expect(mockQueryBus.execute).toHaveBeenCalledWith(
new ValuationComparisonQuery(['prop-1', 'prop-2', 'prop-3']),
);
expect(result).toBe(expected);
});
it('handles two property IDs (minimum)', async () => {
const expected = { properties: [], summary: {} };
mockQueryBus.execute.mockResolvedValue(expected);
const result = await controller.compare({
ids: ['prop-1', 'prop-2'],
} as any);
expect(mockQueryBus.execute).toHaveBeenCalledWith(
new ValuationComparisonQuery(['prop-1', 'prop-2']),
);
expect(result).toBe(expected);
});
});
describe('POST /avm/industrial', () => {
const industrialDto = {
province: 'Bình Dương',
region: 'south',
parkOccupancyRate: 0.85,
parkAreaHa: 500,
parkAgeYears: 10,
distanceToPortKm: 25,
distanceToAirportKm: 40,
distanceToHighwayKm: 5,
propertyType: 'factory',
areaM2: 5000,
ceilingHeightM: 10,
loadingDocks: 4,
zoning: 'general_industrial',
};
it('dispatches IndustrialValuationQuery with all required fields', async () => {
const expected = {
estimatedRentUsdM2: 5.2,
confidence: 0.65,
rentRangeLowUsdM2: 4.16,
rentRangeHighUsdM2: 6.24,
annualRentUsdM2: 62.4,
totalMonthlyRentUsd: 26000,
comparables: [],
drivers: [],
modelVersion: 'heuristic-v1',
};
mockQueryBus.execute.mockResolvedValue(expected);
const result = await controller.industrialValuation(industrialDto as any);
expect(mockQueryBus.execute).toHaveBeenCalledWith(
new IndustrialValuationQuery(
'Bình Dương',
'south',
0.85,
500,
10,
25,
40,
5,
'factory',
5000,
10,
undefined,
undefined,
undefined,
4,
'general_industrial',
undefined,
undefined,
undefined,
undefined,
),
);
expect(result).toBe(expected);
});
it('passes optional fields when provided', async () => {
const fullDto = {
...industrialDto,
floorLoadTonM2: 3,
powerCapacityKva: 2000,
buildingCoverage: 0.6,
industryDemandIndex: 0.7,
fdiProvinceMusd: 3000,
laborCostProvinceVnd: 8000000,
logisticsConnectivityScore: 0.75,
};
const expected = {
estimatedRentUsdM2: 5.8,
confidence: 0.72,
comparables: [],
drivers: [],
};
mockQueryBus.execute.mockResolvedValue(expected);
const result = await controller.industrialValuation(fullDto as any);
const call = mockQueryBus.execute.mock.calls[0]![0] as IndustrialValuationQuery;
expect(call.province).toBe('Bình Dương');
expect(call.floorLoadTonM2).toBe(3);
expect(call.powerCapacityKva).toBe(2000);
expect(call.buildingCoverage).toBe(0.6);
expect(call.logisticsConnectivityScore).toBe(0.75);
expect(result).toBe(expected);
});
});
});

View File

@@ -0,0 +1,126 @@
import {
Body,
Controller,
Get,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { type QueryBus } from '@nestjs/cqrs';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam, ApiQuery } from '@nestjs/swagger';
import { JwtAuthGuard } from '@modules/auth';
import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared';
import { RequireQuota, QuotaGuard } from '@modules/subscriptions';
import { type BatchValuationDto as BatchValuationResultDto } from '../../application/queries/batch-valuation/batch-valuation.handler';
import { BatchValuationQuery } from '../../application/queries/batch-valuation/batch-valuation.query';
import { type IndustrialValuationDto as IndustrialValuationResultDto } from '../../application/queries/industrial-valuation/industrial-valuation.handler';
import { IndustrialValuationQuery } from '../../application/queries/industrial-valuation/industrial-valuation.query';
import { type ValuationComparisonDto as ValuationComparisonResultDto } from '../../application/queries/valuation-comparison/valuation-comparison.handler';
import { ValuationComparisonQuery } from '../../application/queries/valuation-comparison/valuation-comparison.query';
import { type ValuationHistoryDto as ValuationHistoryResultDto } from '../../application/queries/valuation-history/valuation-history.handler';
import { ValuationHistoryQuery } from '../../application/queries/valuation-history/valuation-history.query';
import { type AvmCompareQueryDto } from '../dto/avm-compare-query.dto';
import { type BatchValuationDto } from '../dto/batch-valuation.dto';
import { type IndustrialValuationDto } from '../dto/industrial-valuation.dto';
import { type ValuationHistoryDto } from '../dto/valuation-history.dto';
@ApiTags('avm')
@Controller('avm')
export class AvmController {
constructor(
private readonly queryBus: QueryBus,
) {}
@ApiBearerAuth('JWT')
@EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' })
@UseGuards(EndpointRateLimitGuard, JwtAuthGuard, QuotaGuard)
@RequireQuota('analytics_queries')
@Post('batch')
@ApiOperation({ summary: 'Batch valuation for multiple properties (max 50)' })
@ApiResponse({ status: 200, description: 'Batch valuation results' })
@ApiResponse({ status: 400, description: 'Invalid parameters' })
@ApiResponse({ status: 403, description: 'Quota exceeded' })
@ApiResponse({ status: 429, description: 'Rate limit exceeded — max 10 requests per 60s' })
async batchValuation(@Body() dto: BatchValuationDto): Promise<BatchValuationResultDto> {
return this.queryBus.execute(
new BatchValuationQuery(dto.propertyIds),
);
}
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, QuotaGuard)
@RequireQuota('analytics_queries')
@Get('history/:propertyId')
@ApiOperation({ summary: 'Get valuation history for a property (time-series)' })
@ApiParam({ name: 'propertyId', description: 'Property ID', example: 'prop-123' })
@ApiResponse({ status: 200, description: 'Valuation history time-series data' })
@ApiResponse({ status: 403, description: 'Quota exceeded' })
async getHistory(
@Param('propertyId') propertyId: string,
@Query() dto: ValuationHistoryDto,
): Promise<ValuationHistoryResultDto> {
return this.queryBus.execute(
new ValuationHistoryQuery(propertyId, dto.limit ?? 50),
);
}
@ApiBearerAuth('JWT')
@EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' })
@UseGuards(EndpointRateLimitGuard, JwtAuthGuard, QuotaGuard)
@RequireQuota('analytics_queries')
@Get('compare')
@ApiOperation({ summary: 'Compare valuations for 2-5 properties side by side' })
@ApiQuery({
name: 'ids',
description: 'Comma-separated property IDs (2-5)',
example: 'prop-1,prop-2,prop-3',
type: String,
})
@ApiResponse({ status: 200, description: 'Normalized comparison data for UI' })
@ApiResponse({ status: 400, description: 'Invalid parameters — provide 2-5 property IDs' })
@ApiResponse({ status: 403, description: 'Quota exceeded' })
@ApiResponse({ status: 429, description: 'Rate limit exceeded — max 10 requests per 60s' })
async compare(@Query() dto: AvmCompareQueryDto): Promise<ValuationComparisonResultDto> {
return this.queryBus.execute(
new ValuationComparisonQuery(dto.ids),
);
}
@ApiBearerAuth('JWT')
@EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' })
@UseGuards(EndpointRateLimitGuard, JwtAuthGuard, QuotaGuard)
@RequireQuota('analytics_queries')
@Post('industrial')
@ApiOperation({ summary: 'Estimate industrial property rent using AI model' })
@ApiResponse({ status: 200, description: 'Industrial rent estimation with comparables and drivers' })
@ApiResponse({ status: 400, description: 'Invalid parameters' })
@ApiResponse({ status: 403, description: 'Quota exceeded' })
@ApiResponse({ status: 429, description: 'Rate limit exceeded — max 10 requests per 60s' })
async industrialValuation(@Body() dto: IndustrialValuationDto): Promise<IndustrialValuationResultDto> {
return this.queryBus.execute(
new IndustrialValuationQuery(
dto.province,
dto.region,
dto.parkOccupancyRate,
dto.parkAreaHa,
dto.parkAgeYears,
dto.distanceToPortKm,
dto.distanceToAirportKm,
dto.distanceToHighwayKm,
dto.propertyType,
dto.areaM2,
dto.ceilingHeightM,
dto.floorLoadTonM2,
dto.powerCapacityKva,
dto.buildingCoverage,
dto.loadingDocks,
dto.zoning,
dto.industryDemandIndex,
dto.fdiProvinceMusd,
dto.laborCostProvinceVnd,
dto.logisticsConnectivityScore,
),
);
}
}

View File

@@ -1 +1,2 @@
export { AnalyticsController } from './analytics.controller';
export { AvmController } from './avm.controller';

View File

@@ -0,0 +1,19 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString } from 'class-validator';
export class AvmCompareQueryDto {
@ApiProperty({
description: 'Comma-separated property IDs to compare (2-5)',
example: 'prop-1,prop-2,prop-3',
type: String,
})
@Transform(({ value }) =>
typeof value === 'string' ? value.split(',').map((s: string) => s.trim()).filter(Boolean) : value,
)
@IsArray()
@ArrayMinSize(2)
@ArrayMaxSize(5)
@IsString({ each: true })
ids!: string[];
}

View File

@@ -6,3 +6,5 @@ export { GetValuationDto } from './get-valuation.dto';
export { BatchValuationDto } from './batch-valuation.dto';
export { ValuationHistoryDto } from './valuation-history.dto';
export { ValuationComparisonDto } from './valuation-comparison.dto';
export { AvmCompareQueryDto } from './avm-compare-query.dto';
export { IndustrialValuationDto } from './industrial-valuation.dto';

View File

@@ -0,0 +1,139 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsString, IsNumber, Min, Max, IsOptional } from 'class-validator';
export class IndustrialValuationDto {
@ApiProperty({ description: 'Province name (e.g. Bình Dương)', example: 'Bình Dương' })
@IsString()
province!: string;
@ApiProperty({ description: 'Region: south, north, central, mekong_delta', example: 'south' })
@IsString()
region!: string;
@ApiProperty({ description: 'Park occupancy rate (0-1)', example: 0.85 })
@IsNumber()
@Type(() => Number)
@Min(0)
@Max(1)
parkOccupancyRate!: number;
@ApiProperty({ description: 'Total park area in hectares', example: 500 })
@IsNumber()
@Type(() => Number)
@Min(0)
parkAreaHa!: number;
@ApiProperty({ description: 'Park age in years', example: 10 })
@IsNumber()
@Type(() => Number)
@Min(0)
parkAgeYears!: number;
@ApiProperty({ description: 'Distance to nearest seaport in km', example: 25 })
@IsNumber()
@Type(() => Number)
@Min(0)
distanceToPortKm!: number;
@ApiProperty({ description: 'Distance to nearest airport in km', example: 40 })
@IsNumber()
@Type(() => Number)
@Min(0)
distanceToAirportKm!: number;
@ApiProperty({ description: 'Distance to nearest highway in km', example: 5 })
@IsNumber()
@Type(() => Number)
@Min(0)
distanceToHighwayKm!: number;
@ApiProperty({
description: 'Industrial property type',
example: 'factory',
enum: ['warehouse', 'factory', 'ready_built_factory', 'ready_built_warehouse', 'open_yard', 'office_in_park'],
})
@IsString()
propertyType!: string;
@ApiProperty({ description: 'Leasable area in m²', example: 5000 })
@IsNumber()
@Type(() => Number)
@Min(1)
areaM2!: number;
@ApiPropertyOptional({ description: 'Ceiling height in meters', example: 10 })
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(0)
ceilingHeightM?: number;
@ApiPropertyOptional({ description: 'Floor load capacity in tons/m²', example: 3 })
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(0)
floorLoadTonM2?: number;
@ApiPropertyOptional({ description: 'Power capacity in kVA', example: 2000 })
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(0)
powerCapacityKva?: number;
@ApiPropertyOptional({ description: 'Building coverage ratio (0-1)', example: 0.6 })
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(0)
@Max(1)
buildingCoverage?: number;
@ApiPropertyOptional({ description: 'Number of loading docks', example: 4 })
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(0)
loadingDocks?: number;
@ApiPropertyOptional({
description: 'Industrial zoning category',
example: 'general_industrial',
enum: ['general_industrial', 'heavy_industrial', 'light_industrial', 'logistics', 'free_trade_zone', 'high_tech'],
})
@IsOptional()
@IsString()
zoning?: string;
@ApiPropertyOptional({ description: 'Local industry demand index (0-1)', example: 0.7 })
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(0)
@Max(1)
industryDemandIndex?: number;
@ApiPropertyOptional({ description: 'Province FDI inflow in million USD', example: 3000 })
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(0)
fdiProvinceMusd?: number;
@ApiPropertyOptional({ description: 'Average province labor cost in VND/month', example: 8000000 })
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(0)
laborCostProvinceVnd?: number;
@ApiPropertyOptional({ description: 'Logistics connectivity score (0-1)', example: 0.7 })
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(0)
@Max(1)
logisticsConnectivityScore?: number;
}

View File

@@ -0,0 +1,207 @@
import { UserEntity } from '../../domain/entities/user.entity';
import { type IUserRepository } from '../../domain/repositories/user.repository';
import { type HashedPassword } from '../../domain/value-objects/hashed-password.vo';
import { Phone } from '../../domain/value-objects/phone.vo';
import { type IMediaStorageService } from '../../../../listings/infrastructure/services/media-storage.service';
import { GenerateKycUploadUrlsCommand } from '../commands/generate-kyc-upload-urls/generate-kyc-upload-urls.command';
import { GenerateKycUploadUrlsHandler } from '../commands/generate-kyc-upload-urls/generate-kyc-upload-urls.handler';
function createTestUser(overrides: Partial<{ kycStatus: string }> = {}): UserEntity {
const phone = Phone.create('0912345678').unwrap();
const pw = { value: 'hashed' } as HashedPassword;
return new UserEntity('user-1', {
email: null,
phone,
passwordHash: pw,
fullName: 'Nguyen Van A',
avatarUrl: null,
role: 'BUYER',
kycStatus: overrides.kycStatus ?? 'NONE',
kycData: null,
isActive: true,
});
}
describe('GenerateKycUploadUrlsHandler', () => {
let handler: GenerateKycUploadUrlsHandler;
let mockUserRepo: { [K in keyof IUserRepository]: ReturnType<typeof vi.fn> };
let mockMediaStorage: { [K in keyof IMediaStorageService]: ReturnType<typeof vi.fn> };
let mockLogger: { error: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; log: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockUserRepo = {
findById: vi.fn(),
findByPhone: vi.fn(),
findByEmail: vi.fn(),
save: vi.fn(),
update: vi.fn(),
};
mockMediaStorage = {
upload: vi.fn(),
delete: vi.fn(),
getPresignedUploadUrl: vi.fn(),
generatePresignedUpload: vi.fn(),
getPublicUrl: vi.fn(),
};
mockLogger = {
error: vi.fn(),
warn: vi.fn(),
log: vi.fn(),
};
handler = new GenerateKycUploadUrlsHandler(
mockUserRepo as any,
mockMediaStorage as any,
mockLogger as any,
);
});
it('generates presigned URLs for valid files', async () => {
const user = createTestUser();
mockUserRepo.findById.mockResolvedValue(user);
mockMediaStorage.generatePresignedUpload.mockResolvedValue({
uploadUrl: 'https://minio/upload',
publicUrl: 'https://minio/public',
objectKey: 'kyc/user-1/front.jpg',
});
const command = new GenerateKycUploadUrlsCommand('user-1', [
{ field: 'frontImage', mimeType: 'image/jpeg', fileName: 'front.jpg' },
]);
const result = await handler.execute(command);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
field: 'frontImage',
uploadUrl: 'https://minio/upload',
publicUrl: 'https://minio/public',
objectKey: 'kyc/user-1/front.jpg',
});
expect(mockMediaStorage.generatePresignedUpload).toHaveBeenCalledWith(
'kyc/user-1',
'front.jpg',
'image/jpeg',
300,
);
});
it('generates presigned URLs for multiple files', async () => {
const user = createTestUser();
mockUserRepo.findById.mockResolvedValue(user);
mockMediaStorage.generatePresignedUpload.mockResolvedValue({
uploadUrl: 'https://minio/upload',
publicUrl: 'https://minio/public',
objectKey: 'kyc/user-1/file.jpg',
});
const command = new GenerateKycUploadUrlsCommand('user-1', [
{ field: 'frontImage', mimeType: 'image/jpeg', fileName: 'front.jpg' },
{ field: 'backImage', mimeType: 'image/png', fileName: 'back.png' },
{ field: 'selfieImage', mimeType: 'image/webp', fileName: 'selfie.webp' },
]);
const result = await handler.execute(command);
expect(result).toHaveLength(3);
expect(mockMediaStorage.generatePresignedUpload).toHaveBeenCalledTimes(3);
});
it('allows resubmission when kycStatus is REJECTED', async () => {
const user = createTestUser({ kycStatus: 'REJECTED' });
mockUserRepo.findById.mockResolvedValue(user);
mockMediaStorage.generatePresignedUpload.mockResolvedValue({
uploadUrl: 'https://minio/upload',
publicUrl: 'https://minio/public',
objectKey: 'kyc/user-1/front.jpg',
});
const command = new GenerateKycUploadUrlsCommand('user-1', [
{ field: 'frontImage', mimeType: 'image/jpeg', fileName: 'front.jpg' },
]);
const result = await handler.execute(command);
expect(result).toHaveLength(1);
});
it('throws NotFoundException when user does not exist', async () => {
mockUserRepo.findById.mockResolvedValue(null);
const command = new GenerateKycUploadUrlsCommand('non-existent', [
{ field: 'frontImage', mimeType: 'image/jpeg', fileName: 'front.jpg' },
]);
await expect(handler.execute(command)).rejects.toThrow();
});
it('throws ValidationException when kycStatus is PENDING', async () => {
const user = createTestUser({ kycStatus: 'PENDING' });
mockUserRepo.findById.mockResolvedValue(user);
const command = new GenerateKycUploadUrlsCommand('user-1', [
{ field: 'frontImage', mimeType: 'image/jpeg', fileName: 'front.jpg' },
]);
await expect(handler.execute(command)).rejects.toThrow();
});
it('throws ValidationException when kycStatus is VERIFIED', async () => {
const user = createTestUser({ kycStatus: 'VERIFIED' });
mockUserRepo.findById.mockResolvedValue(user);
const command = new GenerateKycUploadUrlsCommand('user-1', [
{ field: 'frontImage', mimeType: 'image/jpeg', fileName: 'front.jpg' },
]);
await expect(handler.execute(command)).rejects.toThrow();
});
it('throws ValidationException for empty files array', async () => {
const user = createTestUser();
mockUserRepo.findById.mockResolvedValue(user);
const command = new GenerateKycUploadUrlsCommand('user-1', []);
await expect(handler.execute(command)).rejects.toThrow();
});
it('throws ValidationException for more than 3 files', async () => {
const user = createTestUser();
mockUserRepo.findById.mockResolvedValue(user);
const command = new GenerateKycUploadUrlsCommand('user-1', [
{ field: 'frontImage', mimeType: 'image/jpeg', fileName: '1.jpg' },
{ field: 'backImage', mimeType: 'image/jpeg', fileName: '2.jpg' },
{ field: 'selfieImage', mimeType: 'image/jpeg', fileName: '3.jpg' },
{ field: 'frontImage' as any, mimeType: 'image/jpeg', fileName: '4.jpg' },
]);
await expect(handler.execute(command)).rejects.toThrow();
});
it('throws ValidationException for unsupported MIME type', async () => {
const user = createTestUser();
mockUserRepo.findById.mockResolvedValue(user);
const command = new GenerateKycUploadUrlsCommand('user-1', [
{ field: 'frontImage', mimeType: 'application/pdf', fileName: 'doc.pdf' },
]);
await expect(handler.execute(command)).rejects.toThrow();
});
it('throws ValidationException when presigned URL generation fails', async () => {
const user = createTestUser();
mockUserRepo.findById.mockResolvedValue(user);
mockMediaStorage.generatePresignedUpload.mockRejectedValue(
new Error('S3 connection failed'),
);
const command = new GenerateKycUploadUrlsCommand('user-1', [
{ field: 'frontImage', mimeType: 'image/jpeg', fileName: 'front.jpg' },
]);
await expect(handler.execute(command)).rejects.toThrow();
expect(mockLogger.error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,266 @@
import { UserEntity } from '../../domain/entities/user.entity';
import { type IUserRepository } from '../../domain/repositories/user.repository';
import { type HashedPassword } from '../../domain/value-objects/hashed-password.vo';
import { Phone } from '../../domain/value-objects/phone.vo';
import { type IMediaStorageService } from '../../../../listings/infrastructure/services/media-storage.service';
import { SubmitKycCommand } from '../commands/submit-kyc/submit-kyc.command';
import { SubmitKycHandler } from '../commands/submit-kyc/submit-kyc.handler';
function createTestUser(overrides: Partial<{ kycStatus: string }> = {}): UserEntity {
const phone = Phone.create('0912345678').unwrap();
const pw = { value: 'hashed' } as HashedPassword;
return new UserEntity('user-1', {
email: null,
phone,
passwordHash: pw,
fullName: 'Nguyen Van A',
avatarUrl: null,
role: 'BUYER',
kycStatus: overrides.kycStatus ?? 'NONE',
kycData: null,
isActive: true,
});
}
describe('SubmitKycHandler', () => {
let handler: SubmitKycHandler;
let mockUserRepo: { [K in keyof IUserRepository]: ReturnType<typeof vi.fn> };
let mockMediaStorage: { [K in keyof IMediaStorageService]: ReturnType<typeof vi.fn> };
let mockCache: { invalidate: ReturnType<typeof vi.fn>; buildKey: ReturnType<typeof vi.fn> };
let mockLogger: { error: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; log: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockUserRepo = {
findById: vi.fn(),
findByPhone: vi.fn(),
findByEmail: vi.fn(),
save: vi.fn(),
update: vi.fn(),
};
mockMediaStorage = {
upload: vi.fn(),
delete: vi.fn(),
getPresignedUploadUrl: vi.fn(),
generatePresignedUpload: vi.fn(),
getPublicUrl: vi.fn(),
};
mockCache = {
invalidate: vi.fn().mockResolvedValue(undefined),
buildKey: vi.fn(),
};
mockLogger = {
error: vi.fn(),
warn: vi.fn(),
log: vi.fn(),
};
handler = new SubmitKycHandler(
mockUserRepo as any,
mockMediaStorage as any,
mockCache as any,
mockLogger as any,
);
});
describe('presigned URL flow', () => {
it('submits KYC with presigned image URLs', async () => {
const user = createTestUser();
mockUserRepo.findById.mockResolvedValue(user);
mockUserRepo.update.mockResolvedValue(undefined);
const command = new SubmitKycCommand(
'user-1',
'CCCD',
'012345678901',
undefined,
undefined,
undefined,
{
frontImageUrl: 'https://minio/kyc/user-1/front.jpg',
backImageUrl: 'https://minio/kyc/user-1/back.jpg',
selfieUrl: 'https://minio/kyc/user-1/selfie.jpg',
},
);
const result = await handler.execute(command);
expect(result).toEqual({ message: expect.any(String) });
expect(mockUserRepo.update).toHaveBeenCalledWith(user);
expect(user.kycStatus).toBe('PENDING');
expect(mockCache.invalidate).toHaveBeenCalled();
});
it('submits KYC with only front image URL', async () => {
const user = createTestUser();
mockUserRepo.findById.mockResolvedValue(user);
mockUserRepo.update.mockResolvedValue(undefined);
const command = new SubmitKycCommand(
'user-1',
'CCCD',
'012345678901',
undefined,
undefined,
undefined,
{ frontImageUrl: 'https://minio/kyc/user-1/front.jpg' },
);
const result = await handler.execute(command);
expect(result.message).toBeTruthy();
expect(user.kycStatus).toBe('PENDING');
expect(user.kycData).toMatchObject({
idType: 'CCCD',
idNumber: '012345678901',
frontImageUrl: 'https://minio/kyc/user-1/front.jpg',
backImageUrl: null,
selfieUrl: null,
});
});
it('allows resubmission when kycStatus is REJECTED', async () => {
const user = createTestUser({ kycStatus: 'REJECTED' });
mockUserRepo.findById.mockResolvedValue(user);
mockUserRepo.update.mockResolvedValue(undefined);
const command = new SubmitKycCommand(
'user-1',
'PASSPORT',
'B12345678',
undefined,
undefined,
undefined,
{ frontImageUrl: 'https://minio/kyc/user-1/front.jpg' },
);
const result = await handler.execute(command);
expect(result.message).toBeTruthy();
expect(user.kycStatus).toBe('PENDING');
});
});
describe('legacy file upload flow', () => {
it('submits KYC with file buffers', async () => {
const user = createTestUser();
mockUserRepo.findById.mockResolvedValue(user);
mockUserRepo.update.mockResolvedValue(undefined);
mockMediaStorage.upload.mockResolvedValue('https://minio/kyc/user-1/front.jpg');
const command = new SubmitKycCommand(
'user-1',
'CCCD',
'012345678901',
{ buffer: Buffer.from('front'), mimetype: 'image/jpeg', originalname: 'front.jpg', size: 5 },
);
const result = await handler.execute(command);
expect(result.message).toBeTruthy();
expect(mockMediaStorage.upload).toHaveBeenCalledTimes(1);
expect(user.kycStatus).toBe('PENDING');
});
it('uploads all optional files when provided', async () => {
const user = createTestUser();
mockUserRepo.findById.mockResolvedValue(user);
mockUserRepo.update.mockResolvedValue(undefined);
mockMediaStorage.upload.mockResolvedValue('https://minio/kyc/user-1/file.jpg');
const fileData = { buffer: Buffer.from('img'), mimetype: 'image/jpeg', originalname: 'img.jpg', size: 3 };
const command = new SubmitKycCommand(
'user-1',
'CMND',
'123456789',
fileData,
fileData,
fileData,
);
await handler.execute(command);
expect(mockMediaStorage.upload).toHaveBeenCalledTimes(3);
});
});
describe('error cases', () => {
it('throws NotFoundException when user does not exist', async () => {
mockUserRepo.findById.mockResolvedValue(null);
const command = new SubmitKycCommand(
'non-existent',
'CCCD',
'012345678901',
undefined,
undefined,
undefined,
{ frontImageUrl: 'https://minio/front.jpg' },
);
await expect(handler.execute(command)).rejects.toThrow();
});
it('throws ValidationException when kycStatus is PENDING', async () => {
const user = createTestUser({ kycStatus: 'PENDING' });
mockUserRepo.findById.mockResolvedValue(user);
const command = new SubmitKycCommand(
'user-1',
'CCCD',
'012345678901',
undefined,
undefined,
undefined,
{ frontImageUrl: 'https://minio/front.jpg' },
);
await expect(handler.execute(command)).rejects.toThrow();
});
it('throws ValidationException when kycStatus is VERIFIED', async () => {
const user = createTestUser({ kycStatus: 'VERIFIED' });
mockUserRepo.findById.mockResolvedValue(user);
const command = new SubmitKycCommand(
'user-1',
'CCCD',
'012345678901',
undefined,
undefined,
undefined,
{ frontImageUrl: 'https://minio/front.jpg' },
);
await expect(handler.execute(command)).rejects.toThrow();
});
it('throws ValidationException when no images provided', async () => {
const user = createTestUser();
mockUserRepo.findById.mockResolvedValue(user);
const command = new SubmitKycCommand(
'user-1',
'CCCD',
'012345678901',
);
await expect(handler.execute(command)).rejects.toThrow();
});
it('throws when legacy file upload fails', async () => {
const user = createTestUser();
mockUserRepo.findById.mockResolvedValue(user);
mockMediaStorage.upload.mockRejectedValue(new Error('S3 error'));
const command = new SubmitKycCommand(
'user-1',
'CCCD',
'012345678901',
{ buffer: Buffer.from('front'), mimetype: 'image/jpeg', originalname: 'front.jpg', size: 5 },
);
await expect(handler.execute(command)).rejects.toThrow();
expect(mockLogger.error).toHaveBeenCalled();
});
});
});

View File

@@ -191,4 +191,85 @@ describe('UpdateProfileHandler', () => {
expect(mockUserRepo.update).toHaveBeenCalledWith(user);
expect(mockCache.invalidate).toHaveBeenCalled();
});
it('defers phone change via SMS OTP instead of updating directly', async () => {
const user = createTestUser();
mockUserRepo.findById.mockResolvedValue(user);
mockUserRepo.findByPhone.mockResolvedValue(null);
mockUserRepo.update.mockResolvedValue(undefined);
const command = new UpdateProfileCommand(
'user-1',
undefined,
undefined,
undefined,
'0987654321',
);
const result = await handler.execute(command);
// Phone should NOT change yet — deferred pending OTP
expect(result.phoneNumber).toBe('+84912345678');
expect(result.phoneChangePending).toBe(true);
expect(mockRedis.set).toHaveBeenCalledWith(
'auth:phone_change_otp:user-1',
expect.stringContaining('+84987654321'),
600,
);
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
eventName: 'user.phone_change_requested',
newPhone: '+84987654321',
}),
);
});
it('throws ConflictException when new phone is already taken', async () => {
const user = createTestUser();
const otherUser = createTestUser({ id: 'user-2' });
mockUserRepo.findById.mockResolvedValue(user);
mockUserRepo.findByPhone.mockResolvedValue(otherUser);
const command = new UpdateProfileCommand(
'user-1',
undefined,
undefined,
undefined,
'0987654321',
);
await expect(handler.execute(command)).rejects.toThrow('Số điện thoại đã được sử dụng');
});
it('skips SMS OTP when phone is unchanged', async () => {
const user = createTestUser();
mockUserRepo.findById.mockResolvedValue(user);
mockUserRepo.update.mockResolvedValue(undefined);
const command = new UpdateProfileCommand(
'user-1',
undefined,
undefined,
undefined,
'0912345678',
);
const result = await handler.execute(command);
expect(mockRedis.set).not.toHaveBeenCalled();
expect(mockEventBus.publish).not.toHaveBeenCalled();
expect(result.phoneChangePending).toBeUndefined();
});
it('throws ValidationException for invalid phone format', async () => {
const user = createTestUser();
mockUserRepo.findById.mockResolvedValue(user);
const command = new UpdateProfileCommand(
'user-1',
undefined,
undefined,
undefined,
'not-a-phone',
);
await expect(handler.execute(command)).rejects.toThrow('Số điện thoại');
});
});

View File

@@ -0,0 +1,119 @@
import { UserEntity } from '../../domain/entities/user.entity';
import { type IUserRepository } from '../../domain/repositories/user.repository';
import { type HashedPassword } from '../../domain/value-objects/hashed-password.vo';
import { Phone } from '../../domain/value-objects/phone.vo';
import { VerifyPhoneChangeCommand } from '../commands/verify-phone-change/verify-phone-change.command';
import { VerifyPhoneChangeHandler } from '../commands/verify-phone-change/verify-phone-change.handler';
function createTestUser(overrides?: Partial<{ id: string; phone: string }>): UserEntity {
const phone = Phone.create(overrides?.phone ?? '0912345678').unwrap();
const pw = { value: 'hashed' } as HashedPassword;
return new UserEntity(overrides?.id ?? 'user-1', {
email: null,
phone,
passwordHash: pw,
fullName: 'Nguyen Van A',
avatarUrl: null,
role: 'BUYER',
kycStatus: 'NONE',
kycData: null,
isActive: true,
totpSecret: null,
totpEnabled: false,
totpBackupCodes: [],
totpEnabledAt: null,
});
}
describe('VerifyPhoneChangeHandler', () => {
let handler: VerifyPhoneChangeHandler;
let mockUserRepo: { [K in keyof IUserRepository]: ReturnType<typeof vi.fn> };
let mockRedis: { get: ReturnType<typeof vi.fn>; del: ReturnType<typeof vi.fn>; set: ReturnType<typeof vi.fn> };
let mockCache: { invalidate: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockUserRepo = {
findById: vi.fn(),
findByPhone: vi.fn(),
findByEmail: vi.fn(),
save: vi.fn(),
update: vi.fn(),
updateMfaSecret: vi.fn(),
updateMfaEnabled: vi.fn(),
updateMfaDisabled: vi.fn(),
updateBackupCodes: vi.fn(),
};
mockRedis = {
get: vi.fn(),
del: vi.fn().mockResolvedValue(undefined),
set: vi.fn().mockResolvedValue(undefined),
};
mockCache = { invalidate: vi.fn().mockResolvedValue(undefined) };
handler = new VerifyPhoneChangeHandler(
mockUserRepo as any,
mockRedis as any,
mockCache as any,
{ error: vi.fn() } as any,
);
});
it('verifies SMS OTP and updates phone', async () => {
const user = createTestUser();
const payload = JSON.stringify({ newPhone: '+84987654321', code: '123456' });
mockRedis.get.mockResolvedValue(payload);
mockUserRepo.findById.mockResolvedValue(user);
mockUserRepo.findByPhone.mockResolvedValue(null);
mockUserRepo.update.mockResolvedValue(undefined);
const command = new VerifyPhoneChangeCommand('user-1', '123456');
const result = await handler.execute(command);
expect(result.phoneNumber).toBe('+84987654321');
expect(result.id).toBe('user-1');
expect(mockRedis.del).toHaveBeenCalledWith('auth:phone_change_otp:user-1');
expect(mockUserRepo.update).toHaveBeenCalledWith(user);
expect(mockCache.invalidate).toHaveBeenCalledWith(
expect.stringContaining('user-1'),
);
});
it('throws ValidationException when OTP has expired', async () => {
mockRedis.get.mockResolvedValue(null);
const command = new VerifyPhoneChangeCommand('user-1', '123456');
await expect(handler.execute(command)).rejects.toThrow('hết hạn');
});
it('throws ValidationException when OTP code is wrong', async () => {
const payload = JSON.stringify({ newPhone: '+84987654321', code: '123456' });
mockRedis.get.mockResolvedValue(payload);
const command = new VerifyPhoneChangeCommand('user-1', '999999');
await expect(handler.execute(command)).rejects.toThrow('không đúng');
});
it('throws ConflictException when phone was taken since OTP was issued', async () => {
const user = createTestUser();
const otherUser = createTestUser({ id: 'user-2', phone: '0987654321' });
const payload = JSON.stringify({ newPhone: '+84987654321', code: '123456' });
mockRedis.get.mockResolvedValue(payload);
mockUserRepo.findById.mockResolvedValue(user);
mockUserRepo.findByPhone.mockResolvedValue(otherUser);
const command = new VerifyPhoneChangeCommand('user-1', '123456');
await expect(handler.execute(command)).rejects.toThrow('Số điện thoại đã được sử dụng');
// OTP should be cleaned up on conflict
expect(mockRedis.del).toHaveBeenCalledWith('auth:phone_change_otp:user-1');
});
it('throws NotFoundException when user does not exist', async () => {
const payload = JSON.stringify({ newPhone: '+84987654321', code: '123456' });
mockRedis.get.mockResolvedValue(payload);
mockUserRepo.findById.mockResolvedValue(null);
const command = new VerifyPhoneChangeCommand('user-1', '123456');
await expect(handler.execute(command)).rejects.toThrow('Người dùng');
});
});

View File

@@ -4,5 +4,6 @@ export class UpdateProfileCommand {
public readonly fullName?: string,
public readonly avatarUrl?: string,
public readonly email?: string,
public readonly phoneNumber?: string,
) {}
}

View File

@@ -12,8 +12,10 @@ import {
ValidationException,
} from '@modules/shared';
import { EmailChangeRequestedEvent } from '../../../domain/events/email-change-requested.event';
import { PhoneChangeRequestedEvent } from '../../../domain/events/phone-change-requested.event';
import { type IUserRepository, USER_REPOSITORY } from '../../../domain/repositories/user.repository';
import { Email } from '../../../domain/value-objects/email.vo';
import { Phone } from '../../../domain/value-objects/phone.vo';
import { UpdateProfileCommand } from './update-profile.command';
/** TTL for email-change OTP codes stored in Redis (10 minutes). */
@@ -22,12 +24,20 @@ const EMAIL_CHANGE_OTP_TTL = 600;
/** Redis key prefix for pending email-change OTP. */
export const EMAIL_CHANGE_OTP_PREFIX = 'auth:email_change_otp';
/** TTL for phone-change OTP codes stored in Redis (10 minutes). */
const PHONE_CHANGE_OTP_TTL = 600;
/** Redis key prefix for pending phone-change OTP. */
export const PHONE_CHANGE_OTP_PREFIX = 'auth:phone_change_otp';
export interface UpdateProfileResultDto {
id: string;
fullName: string;
avatarUrl: string | null;
email: string | null;
phoneNumber: string;
emailChangePending?: boolean;
phoneChangePending?: boolean;
updatedAt: Date;
}
@@ -49,6 +59,7 @@ export class UpdateProfileHandler implements ICommandHandler<UpdateProfileComman
}
let emailChangePending = false;
let phoneChangePending = false;
// Validate and handle email change via OTP
if (command.email !== undefined) {
@@ -84,7 +95,41 @@ export class UpdateProfileHandler implements ICommandHandler<UpdateProfileComman
}
}
// Apply non-email fields immediately
// Validate and handle phone change via SMS OTP
if (command.phoneNumber !== undefined) {
const phoneResult = Phone.create(command.phoneNumber);
if (phoneResult.isErr) {
throw new ValidationException(phoneResult.unwrapErr());
}
const phone = phoneResult.unwrap();
// Check if phone is actually changing
if (user.phone.value !== phone.value) {
// Check uniqueness
const existingUser = await this.userRepo.findByPhone(phone.value);
if (existingUser && existingUser.id !== command.userId) {
throw new ConflictException('Số điện thoại đã được sử dụng bởi tài khoản khác');
}
// Generate OTP and store pending change in Redis
const otpCode = String(randomInt(100_000, 999_999));
const payload = JSON.stringify({ newPhone: phone.value, code: otpCode });
await this.redis.set(
`${PHONE_CHANGE_OTP_PREFIX}:${command.userId}`,
payload,
PHONE_CHANGE_OTP_TTL,
);
// Emit event so notifications module can send the SMS OTP
this.eventBus.publish(
new PhoneChangeRequestedEvent(command.userId, phone.value, otpCode),
);
phoneChangePending = true;
}
}
// Apply non-email / non-phone fields immediately
user.updateProfile(command.fullName, command.avatarUrl, undefined);
await this.userRepo.update(user);
@@ -97,7 +142,9 @@ export class UpdateProfileHandler implements ICommandHandler<UpdateProfileComman
fullName: user.fullName,
avatarUrl: user.avatarUrl,
email: user.email?.value ?? null,
phoneNumber: user.phone.value,
...(emailChangePending ? { emailChangePending: true } : {}),
...(phoneChangePending ? { phoneChangePending: true } : {}),
updatedAt: user.updatedAt,
};
} catch (error) {

View File

@@ -0,0 +1,6 @@
export class VerifyPhoneChangeCommand {
constructor(
public readonly userId: string,
public readonly code: string,
) {}
}

View File

@@ -0,0 +1,87 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import {
CachePrefix,
CacheService,
ConflictException,
DomainException,
type LoggerService,
NotFoundException,
type RedisService,
ValidationException,
} from '@modules/shared';
import { type IUserRepository, USER_REPOSITORY } from '../../../domain/repositories/user.repository';
import { Phone } from '../../../domain/value-objects/phone.vo';
import { PHONE_CHANGE_OTP_PREFIX } from '../update-profile/update-profile.handler';
import { VerifyPhoneChangeCommand } from './verify-phone-change.command';
export interface VerifyPhoneChangeResultDto {
id: string;
phoneNumber: string;
updatedAt: Date;
}
@CommandHandler(VerifyPhoneChangeCommand)
export class VerifyPhoneChangeHandler implements ICommandHandler<VerifyPhoneChangeCommand> {
constructor(
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
private readonly redis: RedisService,
private readonly cache: CacheService,
private readonly logger: LoggerService,
) {}
async execute(command: VerifyPhoneChangeCommand): Promise<VerifyPhoneChangeResultDto> {
try {
const redisKey = `${PHONE_CHANGE_OTP_PREFIX}:${command.userId}`;
const raw = await this.redis.get(redisKey);
if (!raw) {
throw new ValidationException(
'Mã xác thực đã hết hạn hoặc không tồn tại. Vui lòng yêu cầu đổi số điện thoại lại.',
);
}
const { newPhone, code } = JSON.parse(raw) as { newPhone: string; code: string };
if (code !== command.code) {
throw new ValidationException('Mã xác thực không đúng');
}
const user = await this.userRepo.findById(command.userId);
if (!user) {
throw new NotFoundException('Người dùng', command.userId);
}
// Re-check phone uniqueness (may have been taken since the request)
const existingUser = await this.userRepo.findByPhone(newPhone);
if (existingUser && existingUser.id !== command.userId) {
await this.redis.del(redisKey);
throw new ConflictException('Số điện thoại đã được sử dụng bởi tài khoản khác');
}
const phoneVo = Phone.create(newPhone).unwrap();
user.updatePhone(phoneVo);
await this.userRepo.update(user);
// Clean up OTP and invalidate profile cache
await this.redis.del(redisKey);
await this.cache.invalidate(
CacheService.buildKey(CachePrefix.USER_PROFILE, command.userId),
);
return {
id: user.id,
phoneNumber: phoneVo.value,
updatedAt: user.updatedAt,
};
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to verify phone change: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể xác thực đổi số điện thoại');
}
}
}

View File

@@ -25,6 +25,7 @@ import { VerifyEmailChangeHandler } from './application/commands/verify-email-ch
import { VerifyKycHandler } from './application/commands/verify-kyc/verify-kyc.handler';
import { VerifyMfaChallengeHandler } from './application/commands/verify-mfa-challenge/verify-mfa-challenge.handler';
import { VerifyMfaSetupHandler } from './application/commands/verify-mfa-setup/verify-mfa-setup.handler';
import { VerifyPhoneChangeHandler } from './application/commands/verify-phone-change/verify-phone-change.handler';
import { GetAgentByUserIdHandler } from './application/queries/get-agent-by-user-id/get-agent-by-user-id.handler';
import { GetMfaStatusHandler } from './application/queries/get-mfa-status/get-mfa-status.handler';
import { GetProfileHandler } from './application/queries/get-profile/get-profile.handler';
@@ -55,6 +56,7 @@ const CommandHandlers = [
GenerateKycUploadUrlsHandler,
UpdateProfileHandler,
VerifyEmailChangeHandler,
VerifyPhoneChangeHandler,
RequestUserDeletionHandler,
CancelUserDeletionHandler,
ForceDeleteUserHandler,

View File

@@ -145,4 +145,9 @@ export class UserEntity extends AggregateRoot<string> {
if (email !== undefined) this._email = email;
this.updatedAt = new Date();
}
updatePhone(phone: Phone): void {
this._phone = phone;
this.updatedAt = new Date();
}
}

View File

@@ -1,3 +1,4 @@
export { UserRegisteredEvent } from './user-registered.event';
export { AgentVerifiedEvent } from './agent-verified.event';
export { EmailChangeRequestedEvent } from './email-change-requested.event';
export { PhoneChangeRequestedEvent } from './phone-change-requested.event';

View File

@@ -0,0 +1,12 @@
import { type DomainEvent } from '@modules/shared';
export class PhoneChangeRequestedEvent implements DomainEvent {
readonly eventName = 'user.phone_change_requested';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly newPhone: string,
public readonly otpCode: string,
) {}
}

View File

@@ -12,4 +12,5 @@ export { UserDeactivatedEvent } from './domain/events/user-deactivated.event';
export { UserKycUpdatedEvent } from './domain/events/user-kyc-updated.event';
export { UserRegisteredEvent } from './domain/events/user-registered.event';
export { EmailChangeRequestedEvent } from './domain/events/email-change-requested.event';
export { PhoneChangeRequestedEvent } from './domain/events/phone-change-requested.event';
export { USER_REPOSITORY, IUserRepository } from './domain/repositories/user.repository';

View File

@@ -16,9 +16,8 @@ import {
EndpointRateLimit,
EndpointRateLimitGuard,
UnauthorizedException,
ValidationException,
} from '@modules/shared';
import { GenerateKycUploadUrlsCommand, type KycFileRequest } from '../../application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.command';
import { GenerateKycUploadUrlsCommand } from '../../application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.command';
import { LoginUserCommand } from '../../application/commands/login-user/login-user.command';
import { type LoginResult } from '../../application/commands/login-user/login-user.handler';
import { RefreshTokenCommand } from '../../application/commands/refresh-token/refresh-token.command';
@@ -29,6 +28,8 @@ import { type UpdateProfileResultDto } from '../../application/commands/update-p
import { VerifyEmailChangeCommand } from '../../application/commands/verify-email-change/verify-email-change.command';
import { type VerifyEmailChangeResultDto } from '../../application/commands/verify-email-change/verify-email-change.handler';
import { VerifyKycCommand } from '../../application/commands/verify-kyc/verify-kyc.command';
import { VerifyPhoneChangeCommand } from '../../application/commands/verify-phone-change/verify-phone-change.command';
import { type VerifyPhoneChangeResultDto } from '../../application/commands/verify-phone-change/verify-phone-change.handler';
import { type AgentDto } from '../../application/queries/get-agent-by-user-id/get-agent-by-user-id.handler';
import { GetAgentByUserIdQuery } from '../../application/queries/get-agent-by-user-id/get-agent-by-user-id.query';
import { type UserProfileDto } from '../../application/queries/get-profile/get-profile.handler';
@@ -37,12 +38,15 @@ import { type TokenService, type JwtPayload, type TokenPair } from '../../infras
import { type LocalStrategyResult } from '../../infrastructure/strategies/local.strategy';
import { CurrentUser } from '../decorators/current-user.decorator';
import { Roles } from '../decorators/roles.decorator';
import { type GenerateKycUploadUrlsDto } from '../dto/generate-kyc-upload-urls.dto';
import { LoginDto } from '../dto/login.dto';
import { type RefreshTokenDto } from '../dto/refresh-token.dto';
import { type RegisterDto } from '../dto/register.dto';
import { type SubmitKycDto } from '../dto/submit-kyc.dto';
import { type UpdateProfileDto } from '../dto/update-profile.dto';
import { type VerifyEmailChangeDto } from '../dto/verify-email-change.dto';
import { type VerifyKycDto } from '../dto/verify-kyc.dto';
import { type VerifyPhoneChangeDto } from '../dto/verify-phone-change.dto';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { LocalAuthGuard } from '../guards/local-auth.guard';
import { RolesGuard } from '../guards/roles.guard';
@@ -227,11 +231,29 @@ export class AuthController {
@Body() dto: UpdateProfileDto,
): Promise<{ message: string; data: UpdateProfileResultDto }> {
const result: UpdateProfileResultDto = await this.commandBus.execute(
new UpdateProfileCommand(user.sub, dto.fullName, dto.avatarUrl, dto.email),
new UpdateProfileCommand(user.sub, dto.fullName, dto.avatarUrl, dto.email, dto.phoneNumber),
);
return { message: 'Cập nhật hồ sơ thành công', data: result };
}
@UseGuards(JwtAuthGuard)
@Post('profile/verify-phone')
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Verify phone number change with SMS OTP code' })
@ApiResponse({ status: 201, description: 'Phone number changed successfully' })
@ApiResponse({ status: 400, description: 'Invalid or expired OTP code' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 409, description: 'Phone number already in use' })
async verifyPhoneChange(
@CurrentUser() user: JwtPayload,
@Body() dto: VerifyPhoneChangeDto,
): Promise<{ message: string; data: VerifyPhoneChangeResultDto }> {
const result: VerifyPhoneChangeResultDto = await this.commandBus.execute(
new VerifyPhoneChangeCommand(user.sub, dto.code),
);
return { message: 'Số điện thoại đã được cập nhật thành công', data: result };
}
@UseGuards(JwtAuthGuard)
@Post('profile/verify-email')
@ApiBearerAuth('JWT')
@@ -268,7 +290,7 @@ export class AuthController {
@ApiResponse({ status: 400, description: 'Validation error' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async generateKycUploadUrls(
@Body() body: { files: KycFileRequest[] },
@Body() body: GenerateKycUploadUrlsDto,
@CurrentUser() user: JwtPayload,
): Promise<{ field: string; uploadUrl: string; publicUrl: string; objectKey: string }[]> {
return this.commandBus.execute(
@@ -284,20 +306,9 @@ export class AuthController {
@ApiResponse({ status: 400, description: 'Validation error' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async submitKyc(
@Body()
body: {
documentType: string;
documentNumber: string;
frontImageUrl: string;
backImageUrl?: string;
selfieUrl?: string;
},
@Body() body: SubmitKycDto,
@CurrentUser() user: JwtPayload,
): Promise<{ message: string }> {
if (!body.frontImageUrl) {
throw new ValidationException('Vui lòng tải ảnh mặt trước giấy tờ');
}
return this.commandBus.execute(
new SubmitKycCommand(
user.sub,

View File

@@ -2,4 +2,5 @@ export { RegisterDto } from './register.dto';
export { LoginDto } from './login.dto';
export { RefreshTokenDto } from './refresh-token.dto';
export { VerifyKycDto } from './verify-kyc.dto';
export { GenerateKycUploadUrlsDto, KycFileRequestDto } from './generate-kyc-upload-urls.dto';
export { VerifyMfaSetupDto, VerifyMfaChallengeDto, UseBackupCodeDto, DisableMfaDto } from './mfa.dto';

View File

@@ -21,4 +21,13 @@ export class UpdateProfileDto {
@IsOptional()
@IsEmail({}, { message: 'Email không hợp lệ' })
email?: string;
@ApiPropertyOptional({
example: '0912345678',
description: 'Vietnamese phone number (will trigger SMS OTP re-verification)',
})
@IsOptional()
@IsString()
@MinLength(9, { message: 'Số điện thoại không hợp lệ' })
phoneNumber?: string;
}

View File

@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Length } from 'class-validator';
export class VerifyPhoneChangeDto {
@ApiProperty({ example: '123456', description: '6-digit OTP code sent via SMS' })
@IsNotEmpty({ message: 'Mã xác thực không được để trống' })
@IsString()
@Length(6, 6, { message: 'Mã xác thực phải gồm 6 chữ số' })
code!: string;
}

View File

@@ -0,0 +1,34 @@
import type { IndustrialLeaseType, IndustrialPropertyType } from '@prisma/client';
export class CreateIndustrialListingCommand {
constructor(
public readonly parkId: string,
public readonly sellerId: string,
public readonly agentId: string | null,
public readonly propertyType: IndustrialPropertyType,
public readonly leaseType: IndustrialLeaseType,
public readonly title: string,
public readonly description: string | null,
public readonly areaM2: number,
public readonly ceilingHeightM: number | null,
public readonly floorLoadTonM2: number | null,
public readonly columnSpacingM: number | null,
public readonly dockCount: number | null,
public readonly craneCapacityTon: number | null,
public readonly hasMezzanine: boolean,
public readonly hasOfficeArea: boolean,
public readonly officeAreaM2: number | null,
public readonly priceUsdM2: number | null,
public readonly pricingUnit: string | null,
public readonly totalLeasePrice: number | null,
public readonly managementFee: number | null,
public readonly depositMonths: number | null,
public readonly minLeaseYears: number | null,
public readonly maxLeaseYears: number | null,
public readonly leaseExpiry: Date | null,
public readonly availableFrom: Date | null,
public readonly powerCapacityKva: number | null,
public readonly waterSupplyM3Day: number | null,
public readonly media: Record<string, unknown>[] | null,
) {}
}

View File

@@ -0,0 +1,75 @@
import { Inject } from '@nestjs/common';
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
import { NotFoundException } from '@modules/shared';
import { IndustrialListingEntity } from '../../../domain/entities/industrial-listing.entity';
import {
INDUSTRIAL_LISTING_REPOSITORY,
type IIndustrialListingRepository,
} from '../../../domain/repositories/industrial-listing.repository';
import { type IIndustrialParkRepository, INDUSTRIAL_PARK_REPOSITORY } from '../../../domain/repositories/industrial-park.repository';
import { type TypesenseIndustrialService } from '../../../infrastructure/services/typesense-industrial.service';
import { CreateIndustrialListingCommand } from './create-industrial-listing.command';
@CommandHandler(CreateIndustrialListingCommand)
export class CreateIndustrialListingHandler implements ICommandHandler<CreateIndustrialListingCommand> {
constructor(
@Inject(INDUSTRIAL_LISTING_REPOSITORY)
private readonly repo: IIndustrialListingRepository,
@Inject(INDUSTRIAL_PARK_REPOSITORY)
private readonly parkRepo: IIndustrialParkRepository,
private readonly typesense: TypesenseIndustrialService,
) {}
async execute(cmd: CreateIndustrialListingCommand): Promise<{ id: string }> {
const park = await this.parkRepo.findById(cmd.parkId);
if (!park) {
throw new NotFoundException('Industrial park', cmd.parkId);
}
const now = new Date();
const entity = new IndustrialListingEntity(
createId(),
{
parkId: cmd.parkId,
agentId: cmd.agentId,
sellerId: cmd.sellerId,
propertyType: cmd.propertyType,
leaseType: cmd.leaseType,
status: 'DRAFT',
title: cmd.title,
description: cmd.description,
areaM2: cmd.areaM2,
ceilingHeightM: cmd.ceilingHeightM,
floorLoadTonM2: cmd.floorLoadTonM2,
columnSpacingM: cmd.columnSpacingM,
dockCount: cmd.dockCount,
craneCapacityTon: cmd.craneCapacityTon,
hasMezzanine: cmd.hasMezzanine,
hasOfficeArea: cmd.hasOfficeArea,
officeAreaM2: cmd.officeAreaM2,
priceUsdM2: cmd.priceUsdM2,
pricingUnit: cmd.pricingUnit,
totalLeasePrice: cmd.totalLeasePrice,
managementFee: cmd.managementFee,
depositMonths: cmd.depositMonths,
minLeaseYears: cmd.minLeaseYears,
maxLeaseYears: cmd.maxLeaseYears,
leaseExpiry: cmd.leaseExpiry,
availableFrom: cmd.availableFrom,
powerCapacityKva: cmd.powerCapacityKva,
waterSupplyM3Day: cmd.waterSupplyM3Day,
media: cmd.media,
viewCount: 0,
inquiryCount: 0,
publishedAt: null,
},
now,
now,
);
await this.repo.save(entity);
await this.typesense.indexListing(entity.id).catch(() => {});
return { id: entity.id };
}
}

View File

@@ -0,0 +1,5 @@
export class DeleteIndustrialListingCommand {
constructor(
public readonly id: string,
) {}
}

View File

@@ -0,0 +1,29 @@
import { Inject } from '@nestjs/common';
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
import { NotFoundException } from '@modules/shared';
import {
INDUSTRIAL_LISTING_REPOSITORY,
type IIndustrialListingRepository,
} from '../../../domain/repositories/industrial-listing.repository';
import { type TypesenseIndustrialService } from '../../../infrastructure/services/typesense-industrial.service';
import { DeleteIndustrialListingCommand } from './delete-industrial-listing.command';
@CommandHandler(DeleteIndustrialListingCommand)
export class DeleteIndustrialListingHandler implements ICommandHandler<DeleteIndustrialListingCommand> {
constructor(
@Inject(INDUSTRIAL_LISTING_REPOSITORY)
private readonly repo: IIndustrialListingRepository,
private readonly typesense: TypesenseIndustrialService,
) {}
async execute(cmd: DeleteIndustrialListingCommand): Promise<void> {
const entity = await this.repo.findById(cmd.id);
if (!entity) {
throw new NotFoundException('Industrial listing', cmd.id);
}
entity.softDelete();
await this.repo.update(entity);
await this.typesense.deleteListing(cmd.id).catch(() => {});
}
}

View File

@@ -0,0 +1,33 @@
import type { IndustrialLeaseType, IndustrialListingStatus, IndustrialPropertyType } from '@prisma/client';
export class UpdateIndustrialListingCommand {
constructor(
public readonly id: string,
public readonly propertyType?: IndustrialPropertyType,
public readonly leaseType?: IndustrialLeaseType,
public readonly status?: IndustrialListingStatus,
public readonly title?: string,
public readonly description?: string | null,
public readonly areaM2?: number,
public readonly ceilingHeightM?: number | null,
public readonly floorLoadTonM2?: number | null,
public readonly columnSpacingM?: number | null,
public readonly dockCount?: number | null,
public readonly craneCapacityTon?: number | null,
public readonly hasMezzanine?: boolean,
public readonly hasOfficeArea?: boolean,
public readonly officeAreaM2?: number | null,
public readonly priceUsdM2?: number | null,
public readonly pricingUnit?: string | null,
public readonly totalLeasePrice?: number | null,
public readonly managementFee?: number | null,
public readonly depositMonths?: number | null,
public readonly minLeaseYears?: number | null,
public readonly maxLeaseYears?: number | null,
public readonly leaseExpiry?: Date | null,
public readonly availableFrom?: Date | null,
public readonly powerCapacityKva?: number | null,
public readonly waterSupplyM3Day?: number | null,
public readonly media?: Record<string, unknown>[] | null,
) {}
}

View File

@@ -0,0 +1,57 @@
import { Inject } from '@nestjs/common';
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
import { NotFoundException } from '@modules/shared';
import {
INDUSTRIAL_LISTING_REPOSITORY,
type IIndustrialListingRepository,
} from '../../../domain/repositories/industrial-listing.repository';
import { type TypesenseIndustrialService } from '../../../infrastructure/services/typesense-industrial.service';
import { UpdateIndustrialListingCommand } from './update-industrial-listing.command';
@CommandHandler(UpdateIndustrialListingCommand)
export class UpdateIndustrialListingHandler implements ICommandHandler<UpdateIndustrialListingCommand> {
constructor(
@Inject(INDUSTRIAL_LISTING_REPOSITORY)
private readonly repo: IIndustrialListingRepository,
private readonly typesense: TypesenseIndustrialService,
) {}
async execute(cmd: UpdateIndustrialListingCommand): Promise<void> {
const entity = await this.repo.findById(cmd.id);
if (!entity) {
throw new NotFoundException('Industrial listing', cmd.id);
}
entity.updateDetails({
propertyType: cmd.propertyType,
leaseType: cmd.leaseType,
status: cmd.status,
title: cmd.title,
description: cmd.description,
areaM2: cmd.areaM2,
ceilingHeightM: cmd.ceilingHeightM,
floorLoadTonM2: cmd.floorLoadTonM2,
columnSpacingM: cmd.columnSpacingM,
dockCount: cmd.dockCount,
craneCapacityTon: cmd.craneCapacityTon,
hasMezzanine: cmd.hasMezzanine,
hasOfficeArea: cmd.hasOfficeArea,
officeAreaM2: cmd.officeAreaM2,
priceUsdM2: cmd.priceUsdM2,
pricingUnit: cmd.pricingUnit,
totalLeasePrice: cmd.totalLeasePrice,
managementFee: cmd.managementFee,
depositMonths: cmd.depositMonths,
minLeaseYears: cmd.minLeaseYears,
maxLeaseYears: cmd.maxLeaseYears,
leaseExpiry: cmd.leaseExpiry,
availableFrom: cmd.availableFrom,
powerCapacityKva: cmd.powerCapacityKva,
waterSupplyM3Day: cmd.waterSupplyM3Day,
media: cmd.media,
});
await this.repo.update(entity);
await this.typesense.indexListing(entity.id).catch(() => {});
}
}

View File

@@ -0,0 +1,281 @@
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { PrismaService } from '@modules/shared';
import { AnalyzeIndustrialLocationQuery } from './analyze-industrial-location.query';
interface ConnectivityInfo {
nearest_port?: { name: string; distanceKm: number };
nearest_airport?: { name: string; distanceKm: number };
nearest_highway?: { name: string; distanceKm: number };
nearest_railway?: { name: string; distanceKm: number };
}
interface InfrastructureInfo {
power_availability?: string;
water_supply?: string;
wastewater_treatment?: string;
telecom?: string;
}
interface LocationAnalysisResult {
overall_score: number;
connectivity: ConnectivityInfo;
infrastructure: InfrastructureInfo;
labor_market: {
worker_pool_radius_30km: number | null;
average_wage_usd: number | null;
nearby_universities: string[];
};
incentives: string[];
risks: string[];
nearby_parks: { name: string; distanceKm: number; occupancyRate: number }[];
}
@QueryHandler(AnalyzeIndustrialLocationQuery)
export class AnalyzeIndustrialLocationHandler
implements IQueryHandler<AnalyzeIndustrialLocationQuery, LocationAnalysisResult>
{
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async execute(query: AnalyzeIndustrialLocationQuery): Promise<LocationAnalysisResult> {
const { latitude, longitude, parkName, targetIndustry } = query;
// Find nearest parks within 50km using PostGIS
const nearbyParks = await this.prisma.$queryRaw<
Array<{
id: string;
name: string;
province: string;
region: string;
distanceKm: number;
occupancyRate: number;
landRentUsdM2Year: number | null;
infrastructure: Record<string, unknown> | null;
connectivity: Record<string, unknown> | null;
incentives: Record<string, unknown> | null;
targetIndustries: string[];
}>
>`
SELECT
id, name, province, region,
"occupancyRate",
"landRentUsdM2Year",
infrastructure::jsonb as infrastructure,
connectivity::jsonb as connectivity,
incentives::jsonb as incentives,
"targetIndustries",
ST_Distance(
location::geography,
ST_SetSRID(ST_MakePoint(${longitude}, ${latitude}), 4326)::geography
) / 1000.0 AS "distanceKm"
FROM "IndustrialPark"
WHERE ST_DWithin(
location::geography,
ST_SetSRID(ST_MakePoint(${longitude}, ${latitude}), 4326)::geography,
50000
)
ORDER BY "distanceKm" ASC
LIMIT 10
`;
// If parkName specified, find that specific park
let targetPark = nearbyParks[0] ?? null;
if (parkName) {
const matched = nearbyParks.find(
(p) => p.name.toLowerCase().includes(parkName.toLowerCase()),
);
if (matched) targetPark = matched;
}
// Build connectivity from nearest park data
const connectivity = this.buildConnectivity(targetPark?.connectivity);
// Build infrastructure from nearest park data
const infrastructure = this.buildInfrastructure(targetPark?.infrastructure);
// Compute labor market estimates based on province/region
const laborMarket = this.estimateLaborMarket(targetPark?.province ?? null, targetPark?.region ?? null);
// Gather incentives
const incentives = this.gatherIncentives(targetPark?.incentives);
// Assess risks
const risks = this.assessRisks(nearbyParks, targetIndustry);
// Calculate overall score (0-100)
const overallScore = this.calculateScore(
connectivity,
infrastructure,
nearbyParks,
targetIndustry,
targetPark,
);
return {
overall_score: overallScore,
connectivity,
infrastructure,
labor_market: laborMarket,
incentives,
risks,
nearby_parks: nearbyParks.slice(0, 5).map((p) => ({
name: p.name,
distanceKm: Math.round(p.distanceKm * 10) / 10,
occupancyRate: p.occupancyRate,
})),
};
}
private buildConnectivity(raw: Record<string, unknown> | null | undefined): ConnectivityInfo {
if (!raw) return {};
return {
nearest_port: this.extractFacility(raw, 'nearestPort', 'seaport'),
nearest_airport: this.extractFacility(raw, 'airport', 'nearestAirport'),
nearest_highway: this.extractFacility(raw, 'highway', 'nearestHighway'),
nearest_railway: this.extractFacility(raw, 'railway', 'nearestRailway'),
};
}
private extractFacility(
raw: Record<string, unknown>,
...keys: string[]
): { name: string; distanceKm: number } | undefined {
for (const key of keys) {
const val = raw[key] as Record<string, unknown> | string | undefined;
if (val && typeof val === 'object' && 'name' in val) {
return { name: String(val['name']), distanceKm: Number(val['distanceKm'] ?? val['distance'] ?? 0) };
}
if (typeof val === 'string') {
return { name: val, distanceKm: 0 };
}
}
return undefined;
}
private buildInfrastructure(raw: Record<string, unknown> | null | undefined): InfrastructureInfo {
if (!raw) return {};
return {
power_availability: raw['electricity'] ? String(raw['electricity']) : undefined,
water_supply: raw['water'] ? String(raw['water']) : undefined,
wastewater_treatment: raw['wastewater'] ? String(raw['wastewater']) : undefined,
telecom: raw['telecom'] ? String(raw['telecom']) : undefined,
};
}
private estimateLaborMarket(province: string | null, region: string | null) {
// Regional labor market estimates for Vietnam industrial zones
const regionData: Record<string, { workers: number; wage: number; unis: string[] }> = {
SOUTH: {
workers: 500_000,
wage: 350,
unis: ['ĐH Bách Khoa TP.HCM', 'ĐH Công nghiệp TP.HCM', 'ĐH Tôn Đức Thắng'],
},
NORTH: {
workers: 400_000,
wage: 300,
unis: ['ĐH Bách Khoa Hà Nội', 'ĐH Công nghiệp Hà Nội'],
},
CENTRAL: {
workers: 200_000,
wage: 280,
unis: ['ĐH Bách Khoa Đà Nẵng', 'ĐH Kinh tế Đà Nẵng'],
},
};
const data = regionData[region ?? 'SOUTH'] ?? regionData['SOUTH']!;
return {
worker_pool_radius_30km: data!.workers,
average_wage_usd: data!.wage,
nearby_universities: data!.unis,
};
}
private gatherIncentives(raw: Record<string, unknown> | null | undefined): string[] {
if (!raw) return [];
const result: string[] = [];
if (raw['taxHoliday']) result.push(`Tax holiday: ${raw['taxHoliday']}`);
if (raw['importDuty']) result.push(`Import duty exemption: ${raw['importDuty']}`);
if (raw['landRentReduction']) result.push(`Land rent reduction: ${raw['landRentReduction']}`);
if (raw['specialZone']) result.push(`Special economic zone: ${raw['specialZone']}`);
return result;
}
private assessRisks(
nearbyParks: Array<{ occupancyRate: number; province: string }>,
targetIndustry?: string | null,
): string[] {
const risks: string[] = [];
if (nearbyParks.length === 0) {
risks.push('No industrial parks within 50km — limited industrial ecosystem');
}
const avgOccupancy =
nearbyParks.length > 0
? nearbyParks.reduce((sum, p) => sum + p.occupancyRate, 0) / nearbyParks.length
: 0;
if (avgOccupancy > 90) {
risks.push('High area occupancy (>90%) — limited expansion options');
}
if (targetIndustry) {
// Check if any nearby park targets this industry — simplified check
const hasMatchingPark = nearbyParks.some(
(p) => (p as unknown as { targetIndustries?: string[] }).targetIndustries?.some(
(t) => t.toLowerCase().includes(targetIndustry.toLowerCase()),
),
);
if (!hasMatchingPark) {
risks.push(`No nearby parks specialize in "${targetIndustry}" — may lack ecosystem support`);
}
}
return risks;
}
private calculateScore(
connectivity: ConnectivityInfo,
infrastructure: InfrastructureInfo,
nearbyParks: Array<{ occupancyRate: number; distanceKm: number }>,
targetIndustry?: string | null,
targetPark?: { targetIndustries?: string[]; occupancyRate?: number } | null,
): number {
let score = 50; // Base score
// Connectivity bonus (up to +20)
let connectivityPoints = 0;
if (connectivity.nearest_port) connectivityPoints += 5;
if (connectivity.nearest_airport) connectivityPoints += 5;
if (connectivity.nearest_highway) connectivityPoints += 5;
if (connectivity.nearest_railway) connectivityPoints += 5;
score += connectivityPoints;
// Infrastructure bonus (up to +15)
let infraPoints = 0;
if (infrastructure.power_availability) infraPoints += 4;
if (infrastructure.water_supply) infraPoints += 4;
if (infrastructure.wastewater_treatment) infraPoints += 4;
if (infrastructure.telecom) infraPoints += 3;
score += infraPoints;
// Nearby parks density (up to +10)
if (nearbyParks.length >= 5) score += 10;
else if (nearbyParks.length >= 3) score += 7;
else if (nearbyParks.length >= 1) score += 4;
// Occupancy rate penalty (parks too full = -5)
if (targetPark && targetPark.occupancyRate && targetPark.occupancyRate > 95) {
score -= 5;
}
// Industry match bonus (+5)
if (targetIndustry && targetPark?.targetIndustries?.some(
(t) => t.toLowerCase().includes(targetIndustry.toLowerCase()),
)) {
score += 5;
}
return Math.max(0, Math.min(100, Math.round(score)));
}
}

View File

@@ -0,0 +1,8 @@
export class AnalyzeIndustrialLocationQuery {
constructor(
public readonly latitude: number,
public readonly longitude: number,
public readonly parkName?: string | null,
public readonly targetIndustry?: string | null,
) {}
}

View File

@@ -0,0 +1,185 @@
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { PrismaService } from '@modules/shared';
import { EstimateIndustrialRentQuery } from './estimate-industrial-rent.query';
interface RentEstimateResult {
estimated_rent_usd_m2: number;
pricing_unit: string;
total_monthly_usd: number;
total_lease_usd: number;
management_fee_usd_m2: number | null;
deposit_months: number;
market_comparison: {
province_low: number | null;
province_high: number | null;
province_avg: number | null;
};
breakdown: { item: string; amount: number }[];
}
@QueryHandler(EstimateIndustrialRentQuery)
export class EstimateIndustrialRentHandler
implements IQueryHandler<EstimateIndustrialRentQuery, RentEstimateResult>
{
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async execute(query: EstimateIndustrialRentQuery): Promise<RentEstimateResult> {
const { province, propertyType, areaM2, leaseDurationYears, parkName, requiresCrane, requiredPowerKva, requiresWastewater } = query;
// Get market data for the province
const provinceParks = await this.prisma.industrialPark.findMany({
where: { province: { contains: province, mode: 'insensitive' } },
select: {
name: true,
landRentUsdM2Year: true,
rbfRentUsdM2Month: true,
rbwRentUsdM2Month: true,
managementFeeUsd: true,
occupancyRate: true,
},
});
// If specific park requested, try to find it
let specificPark = parkName
? provinceParks.find((p) => p.name.toLowerCase().includes(parkName.toLowerCase()))
: null;
// Calculate base rent based on property type
const rentField = this.getRentField(propertyType);
const rents = provinceParks
.map((p) => p[rentField] as number | null)
.filter((r): r is number => r != null);
const provinceLow = rents.length > 0 ? Math.min(...rents) : null;
const provinceHigh = rents.length > 0 ? Math.max(...rents) : null;
const provinceAvg = rents.length > 0 ? rents.reduce((a, b) => a + b, 0) / rents.length : null;
// Determine base rent
let baseRentUsdM2: number;
if (specificPark && specificPark[rentField] != null) {
baseRentUsdM2 = specificPark[rentField] as number;
} else if (provinceAvg != null) {
baseRentUsdM2 = provinceAvg;
} else {
// Fallback to national averages by property type
baseRentUsdM2 = this.getNationalAvgRent(propertyType);
}
// Apply adjustments
const breakdown: { item: string; amount: number }[] = [];
let adjustedRent = baseRentUsdM2;
breakdown.push({ item: `Base ${this.getPropertyTypeLabel(propertyType)} rent`, amount: baseRentUsdM2 });
// Crane surcharge
if (requiresCrane) {
const craneSurcharge = baseRentUsdM2 * 0.08;
adjustedRent += craneSurcharge;
breakdown.push({ item: 'Overhead crane surcharge (+8%)', amount: craneSurcharge });
}
// High power requirement surcharge
if (requiredPowerKva && requiredPowerKva > 500) {
const powerSurcharge = baseRentUsdM2 * 0.05;
adjustedRent += powerSurcharge;
breakdown.push({ item: 'High power capacity surcharge (+5%)', amount: powerSurcharge });
}
// Wastewater treatment surcharge
if (requiresWastewater) {
const wastewaterSurcharge = baseRentUsdM2 * 0.03;
adjustedRent += wastewaterSurcharge;
breakdown.push({ item: 'Wastewater treatment surcharge (+3%)', amount: wastewaterSurcharge });
}
// Long lease discount
if (leaseDurationYears >= 20) {
const discount = adjustedRent * 0.10;
adjustedRent -= discount;
breakdown.push({ item: 'Long-term lease discount (≥20yr, -10%)', amount: -discount });
} else if (leaseDurationYears >= 10) {
const discount = adjustedRent * 0.05;
adjustedRent -= discount;
breakdown.push({ item: 'Long-term lease discount (≥10yr, -5%)', amount: -discount });
}
// Large area discount
if (areaM2 >= 10_000) {
const discount = adjustedRent * 0.07;
adjustedRent -= discount;
breakdown.push({ item: 'Large area discount (≥10,000m², -7%)', amount: -discount });
} else if (areaM2 >= 5_000) {
const discount = adjustedRent * 0.03;
adjustedRent -= discount;
breakdown.push({ item: 'Large area discount (≥5,000m², -3%)', amount: -discount });
}
adjustedRent = Math.round(adjustedRent * 100) / 100;
// Determine pricing unit and compute totals
const isMonthlyType = propertyType !== 'industrial_land';
const pricingUnit = isMonthlyType ? 'USD/m²/month' : 'USD/m²/year';
const totalMonthlyUsd = isMonthlyType
? Math.round(adjustedRent * areaM2 * 100) / 100
: Math.round((adjustedRent * areaM2 / 12) * 100) / 100;
const totalLeaseUsd = Math.round(totalMonthlyUsd * 12 * leaseDurationYears * 100) / 100;
// Management fee
const managementFeeUsdM2 = specificPark?.managementFeeUsd ?? (provinceParks.length > 0
? provinceParks.reduce((sum, p) => sum + (p.managementFeeUsd ?? 0), 0) / provinceParks.length || null
: null);
return {
estimated_rent_usd_m2: adjustedRent,
pricing_unit: pricingUnit,
total_monthly_usd: totalMonthlyUsd,
total_lease_usd: totalLeaseUsd,
management_fee_usd_m2: managementFeeUsdM2 ? Math.round(managementFeeUsdM2 * 100) / 100 : null,
deposit_months: leaseDurationYears >= 10 ? 6 : 3,
market_comparison: {
province_low: provinceLow ? Math.round(provinceLow * 100) / 100 : null,
province_high: provinceHigh ? Math.round(provinceHigh * 100) / 100 : null,
province_avg: provinceAvg ? Math.round(provinceAvg * 100) / 100 : null,
},
breakdown: breakdown.map((b) => ({ item: b.item, amount: Math.round(b.amount * 100) / 100 })),
};
}
private getRentField(propertyType: string): 'landRentUsdM2Year' | 'rbfRentUsdM2Month' | 'rbwRentUsdM2Month' {
switch (propertyType) {
case 'ready_built_factory':
return 'rbfRentUsdM2Month';
case 'ready_built_warehouse':
case 'logistics_center':
return 'rbwRentUsdM2Month';
default:
return 'landRentUsdM2Year';
}
}
private getPropertyTypeLabel(propertyType: string): string {
const labels: Record<string, string> = {
industrial_land: 'Industrial land',
ready_built_factory: 'Ready-built factory',
ready_built_warehouse: 'Ready-built warehouse',
logistics_center: 'Logistics center',
office_in_park: 'Office in park',
data_center: 'Data center',
};
return labels[propertyType] ?? propertyType;
}
private getNationalAvgRent(propertyType: string): number {
// Vietnamese national average industrial rents (2024-2025 market data)
const averages: Record<string, number> = {
industrial_land: 120, // USD/m²/year
ready_built_factory: 5.5, // USD/m²/month
ready_built_warehouse: 4.8, // USD/m²/month
logistics_center: 5.0, // USD/m²/month
office_in_park: 8.0, // USD/m²/month
data_center: 12.0, // USD/m²/month
};
return averages[propertyType] ?? 5.0;
}
}

View File

@@ -0,0 +1,12 @@
export class EstimateIndustrialRentQuery {
constructor(
public readonly province: string,
public readonly propertyType: string,
public readonly areaM2: number,
public readonly leaseDurationYears: number,
public readonly parkName?: string | null,
public readonly requiresCrane?: boolean,
public readonly requiredPowerKva?: number | null,
public readonly requiresWastewater?: boolean,
) {}
}

View File

@@ -0,0 +1,20 @@
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import {
INDUSTRIAL_LISTING_REPOSITORY,
type IIndustrialListingRepository,
type IndustrialListingDetailData,
} from '../../../domain/repositories/industrial-listing.repository';
import { GetIndustrialListingQuery } from './get-industrial-listing.query';
@QueryHandler(GetIndustrialListingQuery)
export class GetIndustrialListingHandler implements IQueryHandler<GetIndustrialListingQuery> {
constructor(
@Inject(INDUSTRIAL_LISTING_REPOSITORY)
private readonly repo: IIndustrialListingRepository,
) {}
async execute(query: GetIndustrialListingQuery): Promise<IndustrialListingDetailData | null> {
return this.repo.findDetailById(query.id);
}
}

View File

@@ -0,0 +1,5 @@
export class GetIndustrialListingQuery {
constructor(
public readonly id: string,
) {}
}

View File

@@ -0,0 +1,33 @@
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import {
INDUSTRIAL_LISTING_REPOSITORY,
type IIndustrialListingRepository,
type IndustrialListingListItem,
type PaginatedResult,
} from '../../../domain/repositories/industrial-listing.repository';
import { ListIndustrialListingsQuery } from './list-industrial-listings.query';
@QueryHandler(ListIndustrialListingsQuery)
export class ListIndustrialListingsHandler implements IQueryHandler<ListIndustrialListingsQuery> {
constructor(
@Inject(INDUSTRIAL_LISTING_REPOSITORY)
private readonly repo: IIndustrialListingRepository,
) {}
async execute(query: ListIndustrialListingsQuery): Promise<PaginatedResult<IndustrialListingListItem>> {
return this.repo.search({
parkId: query.parkId,
propertyType: query.propertyType,
leaseType: query.leaseType,
status: query.status,
minAreaM2: query.minAreaM2,
maxAreaM2: query.maxAreaM2,
minPriceUsdM2: query.minPriceUsdM2,
maxPriceUsdM2: query.maxPriceUsdM2,
query: query.query,
page: query.page,
limit: query.limit,
});
}
}

View File

@@ -0,0 +1,17 @@
import type { IndustrialLeaseType, IndustrialListingStatus, IndustrialPropertyType } from '@prisma/client';
export class ListIndustrialListingsQuery {
constructor(
public readonly parkId?: string,
public readonly propertyType?: IndustrialPropertyType,
public readonly leaseType?: IndustrialLeaseType,
public readonly status?: IndustrialListingStatus,
public readonly minAreaM2?: number,
public readonly maxAreaM2?: number,
public readonly minPriceUsdM2?: number,
public readonly maxPriceUsdM2?: number,
public readonly query?: string,
public readonly page: number = 1,
public readonly limit: number = 20,
) {}
}

View File

@@ -0,0 +1,177 @@
import { type IndustrialLeaseType, type IndustrialListingStatus, type IndustrialPropertyType } from '@prisma/client';
import { AggregateRoot } from '@modules/shared';
export interface IndustrialListingProps {
parkId: string;
agentId: string | null;
sellerId: string;
propertyType: IndustrialPropertyType;
leaseType: IndustrialLeaseType;
status: IndustrialListingStatus;
title: string;
description: string | null;
areaM2: number;
ceilingHeightM: number | null;
floorLoadTonM2: number | null;
columnSpacingM: number | null;
dockCount: number | null;
craneCapacityTon: number | null;
hasMezzanine: boolean;
hasOfficeArea: boolean;
officeAreaM2: number | null;
priceUsdM2: number | null;
pricingUnit: string | null;
totalLeasePrice: number | null;
managementFee: number | null;
depositMonths: number | null;
minLeaseYears: number | null;
maxLeaseYears: number | null;
leaseExpiry: Date | null;
availableFrom: Date | null;
powerCapacityKva: number | null;
waterSupplyM3Day: number | null;
media: Record<string, unknown>[] | null;
viewCount: number;
inquiryCount: number;
publishedAt: Date | null;
}
export class IndustrialListingEntity extends AggregateRoot<string> {
private _parkId: string;
private _agentId: string | null;
private _sellerId: string;
private _propertyType: IndustrialPropertyType;
private _leaseType: IndustrialLeaseType;
private _status: IndustrialListingStatus;
private _title: string;
private _description: string | null;
private _areaM2: number;
private _ceilingHeightM: number | null;
private _floorLoadTonM2: number | null;
private _columnSpacingM: number | null;
private _dockCount: number | null;
private _craneCapacityTon: number | null;
private _hasMezzanine: boolean;
private _hasOfficeArea: boolean;
private _officeAreaM2: number | null;
private _priceUsdM2: number | null;
private _pricingUnit: string | null;
private _totalLeasePrice: number | null;
private _managementFee: number | null;
private _depositMonths: number | null;
private _minLeaseYears: number | null;
private _maxLeaseYears: number | null;
private _leaseExpiry: Date | null;
private _availableFrom: Date | null;
private _powerCapacityKva: number | null;
private _waterSupplyM3Day: number | null;
private _media: Record<string, unknown>[] | null;
private _viewCount: number;
private _inquiryCount: number;
private _publishedAt: Date | null;
constructor(id: string, props: IndustrialListingProps, createdAt: Date, updatedAt: Date) {
super(id, createdAt, updatedAt);
this._parkId = props.parkId;
this._agentId = props.agentId;
this._sellerId = props.sellerId;
this._propertyType = props.propertyType;
this._leaseType = props.leaseType;
this._status = props.status;
this._title = props.title;
this._description = props.description;
this._areaM2 = props.areaM2;
this._ceilingHeightM = props.ceilingHeightM;
this._floorLoadTonM2 = props.floorLoadTonM2;
this._columnSpacingM = props.columnSpacingM;
this._dockCount = props.dockCount;
this._craneCapacityTon = props.craneCapacityTon;
this._hasMezzanine = props.hasMezzanine;
this._hasOfficeArea = props.hasOfficeArea;
this._officeAreaM2 = props.officeAreaM2;
this._priceUsdM2 = props.priceUsdM2;
this._pricingUnit = props.pricingUnit;
this._totalLeasePrice = props.totalLeasePrice;
this._managementFee = props.managementFee;
this._depositMonths = props.depositMonths;
this._minLeaseYears = props.minLeaseYears;
this._maxLeaseYears = props.maxLeaseYears;
this._leaseExpiry = props.leaseExpiry;
this._availableFrom = props.availableFrom;
this._powerCapacityKva = props.powerCapacityKva;
this._waterSupplyM3Day = props.waterSupplyM3Day;
this._media = props.media;
this._viewCount = props.viewCount;
this._inquiryCount = props.inquiryCount;
this._publishedAt = props.publishedAt;
}
get parkId() { return this._parkId; }
get agentId() { return this._agentId; }
get sellerId() { return this._sellerId; }
get propertyType() { return this._propertyType; }
get leaseType() { return this._leaseType; }
get status() { return this._status; }
get title() { return this._title; }
get description() { return this._description; }
get areaM2() { return this._areaM2; }
get ceilingHeightM() { return this._ceilingHeightM; }
get floorLoadTonM2() { return this._floorLoadTonM2; }
get columnSpacingM() { return this._columnSpacingM; }
get dockCount() { return this._dockCount; }
get craneCapacityTon() { return this._craneCapacityTon; }
get hasMezzanine() { return this._hasMezzanine; }
get hasOfficeArea() { return this._hasOfficeArea; }
get officeAreaM2() { return this._officeAreaM2; }
get priceUsdM2() { return this._priceUsdM2; }
get pricingUnit() { return this._pricingUnit; }
get totalLeasePrice() { return this._totalLeasePrice; }
get managementFee() { return this._managementFee; }
get depositMonths() { return this._depositMonths; }
get minLeaseYears() { return this._minLeaseYears; }
get maxLeaseYears() { return this._maxLeaseYears; }
get leaseExpiry() { return this._leaseExpiry; }
get availableFrom() { return this._availableFrom; }
get powerCapacityKva() { return this._powerCapacityKva; }
get waterSupplyM3Day() { return this._waterSupplyM3Day; }
get media() { return this._media; }
get viewCount() { return this._viewCount; }
get inquiryCount() { return this._inquiryCount; }
get publishedAt() { return this._publishedAt; }
updateDetails(props: Partial<Omit<IndustrialListingProps, 'parkId' | 'sellerId'>>): void {
if (props.agentId !== undefined) this._agentId = props.agentId;
if (props.propertyType !== undefined) this._propertyType = props.propertyType;
if (props.leaseType !== undefined) this._leaseType = props.leaseType;
if (props.status !== undefined) this._status = props.status;
if (props.title !== undefined) this._title = props.title;
if (props.description !== undefined) this._description = props.description;
if (props.areaM2 !== undefined) this._areaM2 = props.areaM2;
if (props.ceilingHeightM !== undefined) this._ceilingHeightM = props.ceilingHeightM;
if (props.floorLoadTonM2 !== undefined) this._floorLoadTonM2 = props.floorLoadTonM2;
if (props.columnSpacingM !== undefined) this._columnSpacingM = props.columnSpacingM;
if (props.dockCount !== undefined) this._dockCount = props.dockCount;
if (props.craneCapacityTon !== undefined) this._craneCapacityTon = props.craneCapacityTon;
if (props.hasMezzanine !== undefined) this._hasMezzanine = props.hasMezzanine;
if (props.hasOfficeArea !== undefined) this._hasOfficeArea = props.hasOfficeArea;
if (props.officeAreaM2 !== undefined) this._officeAreaM2 = props.officeAreaM2;
if (props.priceUsdM2 !== undefined) this._priceUsdM2 = props.priceUsdM2;
if (props.pricingUnit !== undefined) this._pricingUnit = props.pricingUnit;
if (props.totalLeasePrice !== undefined) this._totalLeasePrice = props.totalLeasePrice;
if (props.managementFee !== undefined) this._managementFee = props.managementFee;
if (props.depositMonths !== undefined) this._depositMonths = props.depositMonths;
if (props.minLeaseYears !== undefined) this._minLeaseYears = props.minLeaseYears;
if (props.maxLeaseYears !== undefined) this._maxLeaseYears = props.maxLeaseYears;
if (props.leaseExpiry !== undefined) this._leaseExpiry = props.leaseExpiry;
if (props.availableFrom !== undefined) this._availableFrom = props.availableFrom;
if (props.powerCapacityKva !== undefined) this._powerCapacityKva = props.powerCapacityKva;
if (props.waterSupplyM3Day !== undefined) this._waterSupplyM3Day = props.waterSupplyM3Day;
if (props.media !== undefined) this._media = props.media;
this.updatedAt = new Date();
}
softDelete(): void {
this._status = 'EXPIRED' as IndustrialListingStatus;
this.updatedAt = new Date();
}
}

View File

@@ -0,0 +1,92 @@
import type { IndustrialLeaseType, IndustrialListingStatus, IndustrialPropertyType } from '@prisma/client';
import type { IndustrialListingEntity } from '../entities/industrial-listing.entity';
export const INDUSTRIAL_LISTING_REPOSITORY = Symbol('INDUSTRIAL_LISTING_REPOSITORY');
export interface IndustrialListingSearchParams {
parkId?: string;
propertyType?: IndustrialPropertyType;
leaseType?: IndustrialLeaseType;
status?: IndustrialListingStatus;
minAreaM2?: number;
maxAreaM2?: number;
minPriceUsdM2?: number;
maxPriceUsdM2?: number;
query?: string;
page?: number;
limit?: number;
}
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface IndustrialListingListItem {
id: string;
parkId: string;
parkName: string;
propertyType: IndustrialPropertyType;
leaseType: IndustrialLeaseType;
status: IndustrialListingStatus;
title: string;
areaM2: number;
priceUsdM2: number | null;
pricingUnit: string | null;
ceilingHeightM: number | null;
hasMezzanine: boolean;
hasOfficeArea: boolean;
publishedAt: Date | null;
createdAt: Date;
}
export interface IndustrialListingDetailData {
id: string;
parkId: string;
parkName: string;
parkSlug: string;
agentId: string | null;
sellerId: string;
propertyType: IndustrialPropertyType;
leaseType: IndustrialLeaseType;
status: IndustrialListingStatus;
title: string;
description: string | null;
areaM2: number;
ceilingHeightM: number | null;
floorLoadTonM2: number | null;
columnSpacingM: number | null;
dockCount: number | null;
craneCapacityTon: number | null;
hasMezzanine: boolean;
hasOfficeArea: boolean;
officeAreaM2: number | null;
priceUsdM2: number | null;
pricingUnit: string | null;
totalLeasePrice: number | null;
managementFee: number | null;
depositMonths: number | null;
minLeaseYears: number | null;
maxLeaseYears: number | null;
leaseExpiry: Date | null;
availableFrom: Date | null;
powerCapacityKva: number | null;
waterSupplyM3Day: number | null;
media: Record<string, unknown>[] | null;
viewCount: number;
inquiryCount: number;
publishedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface IIndustrialListingRepository {
findById(id: string): Promise<IndustrialListingEntity | null>;
findDetailById(id: string): Promise<IndustrialListingDetailData | null>;
save(entity: IndustrialListingEntity): Promise<void>;
update(entity: IndustrialListingEntity): Promise<void>;
search(params: IndustrialListingSearchParams): Promise<PaginatedResult<IndustrialListingListItem>>;
}

View File

@@ -1,40 +1,58 @@
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { SearchModule } from '@modules/search';
import { CreateIndustrialListingHandler } from './application/commands/create-industrial-listing/create-industrial-listing.handler';
import { CreateIndustrialParkHandler } from './application/commands/create-industrial-park/create-industrial-park.handler';
import { DeleteIndustrialListingHandler } from './application/commands/delete-industrial-listing/delete-industrial-listing.handler';
import { UpdateIndustrialListingHandler } from './application/commands/update-industrial-listing/update-industrial-listing.handler';
import { UpdateIndustrialParkHandler } from './application/commands/update-industrial-park/update-industrial-park.handler';
import { AnalyzeIndustrialLocationHandler } from './application/queries/analyze-industrial-location/analyze-industrial-location.handler';
import { CompareIndustrialParksHandler } from './application/queries/compare-industrial-parks/compare-industrial-parks.handler';
import { EstimateIndustrialRentHandler } from './application/queries/estimate-industrial-rent/estimate-industrial-rent.handler';
import { GetIndustrialListingHandler } from './application/queries/get-industrial-listing/get-industrial-listing.handler';
import { GetIndustrialParkHandler } from './application/queries/get-industrial-park/get-industrial-park.handler';
import { IndustrialMarketHandler } from './application/queries/industrial-market/industrial-market.handler';
import { IndustrialParkStatsHandler } from './application/queries/industrial-park-stats/industrial-park-stats.handler';
import { ListIndustrialListingsHandler } from './application/queries/list-industrial-listings/list-industrial-listings.handler';
import { ListIndustrialParksHandler } from './application/queries/list-industrial-parks/list-industrial-parks.handler';
import { INDUSTRIAL_LISTING_REPOSITORY } from './domain/repositories/industrial-listing.repository';
import { INDUSTRIAL_PARK_REPOSITORY } from './domain/repositories/industrial-park.repository';
import { PrismaIndustrialListingRepository } from './infrastructure/repositories/prisma-industrial-listing.repository';
import { PrismaIndustrialParkRepository } from './infrastructure/repositories/prisma-industrial-park.repository';
import { TypesenseIndustrialService } from './infrastructure/services/typesense-industrial.service';
import { IndustrialListingsController } from './presentation/controllers/industrial-listings.controller';
import { IndustrialParksController } from './presentation/controllers/industrial-parks.controller';
const CommandHandlers = [
CreateIndustrialParkHandler,
UpdateIndustrialParkHandler,
CreateIndustrialListingHandler,
UpdateIndustrialListingHandler,
DeleteIndustrialListingHandler,
];
const QueryHandlers = [
AnalyzeIndustrialLocationHandler,
EstimateIndustrialRentHandler,
GetIndustrialParkHandler,
ListIndustrialParksHandler,
CompareIndustrialParksHandler,
IndustrialParkStatsHandler,
IndustrialMarketHandler,
GetIndustrialListingHandler,
ListIndustrialListingsHandler,
];
@Module({
imports: [CqrsModule, SearchModule],
controllers: [IndustrialParksController],
controllers: [IndustrialParksController, IndustrialListingsController],
providers: [
{ provide: INDUSTRIAL_PARK_REPOSITORY, useClass: PrismaIndustrialParkRepository },
{ provide: INDUSTRIAL_LISTING_REPOSITORY, useClass: PrismaIndustrialListingRepository },
TypesenseIndustrialService,
...CommandHandlers,
...QueryHandlers,
],
exports: [INDUSTRIAL_PARK_REPOSITORY, TypesenseIndustrialService],
exports: [INDUSTRIAL_PARK_REPOSITORY, INDUSTRIAL_LISTING_REPOSITORY, TypesenseIndustrialService],
})
export class IndustrialModule {}

View File

@@ -0,0 +1,342 @@
import { Injectable } from '@nestjs/common';
import type { IndustrialLeaseType, IndustrialListingStatus, IndustrialPropertyType, Prisma } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { IndustrialListingEntity } from '../../domain/entities/industrial-listing.entity';
import type {
IIndustrialListingRepository,
IndustrialListingSearchParams,
PaginatedResult,
IndustrialListingListItem,
IndustrialListingDetailData,
} from '../../domain/repositories/industrial-listing.repository';
@Injectable()
export class PrismaIndustrialListingRepository implements IIndustrialListingRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<IndustrialListingEntity | null> {
const rows = await this.prisma.$queryRaw<RawListing[]>`
SELECT * FROM "IndustrialListing" WHERE id = ${id} LIMIT 1
`;
return rows[0] ? this.toDomain(rows[0]) : null;
}
async findDetailById(id: string): Promise<IndustrialListingDetailData | null> {
const rows = await this.prisma.$queryRaw<RawListingDetail[]>`
SELECT l.*, p.name as "parkName", p.slug as "parkSlug"
FROM "IndustrialListing" l
JOIN "IndustrialPark" p ON p.id = l."parkId"
WHERE l.id = ${id}
LIMIT 1
`;
return rows[0] ? this.toDetail(rows[0]) : null;
}
async save(entity: IndustrialListingEntity): Promise<void> {
await this.prisma.$executeRaw`
INSERT INTO "IndustrialListing" (
id, "parkId", "agentId", "sellerId", "propertyType", "leaseType", status,
title, description, "areaM2", "ceilingHeightM", "floorLoadTonM2",
"columnSpacingM", "dockCount", "craneCapacityTon", "hasMezzanine",
"hasOfficeArea", "officeAreaM2", "priceUsdM2", "pricingUnit",
"totalLeasePrice", "managementFee", "depositMonths", "minLeaseYears",
"maxLeaseYears", "leaseExpiry", "availableFrom", "powerCapacityKva",
"waterSupplyM3Day", media, "viewCount", "inquiryCount",
"publishedAt", "createdAt", "updatedAt"
) VALUES (
${entity.id}, ${entity.parkId}, ${entity.agentId}, ${entity.sellerId},
${entity.propertyType}::"IndustrialPropertyType",
${entity.leaseType}::"IndustrialLeaseType",
${entity.status}::"IndustrialListingStatus",
${entity.title}, ${entity.description}, ${entity.areaM2},
${entity.ceilingHeightM}, ${entity.floorLoadTonM2},
${entity.columnSpacingM}, ${entity.dockCount}, ${entity.craneCapacityTon},
${entity.hasMezzanine}, ${entity.hasOfficeArea}, ${entity.officeAreaM2},
${entity.priceUsdM2}, ${entity.pricingUnit}, ${entity.totalLeasePrice},
${entity.managementFee}, ${entity.depositMonths}, ${entity.minLeaseYears},
${entity.maxLeaseYears}, ${entity.leaseExpiry}, ${entity.availableFrom},
${entity.powerCapacityKva}, ${entity.waterSupplyM3Day},
${entity.media ? JSON.stringify(entity.media) : null}::jsonb,
${entity.viewCount}, ${entity.inquiryCount},
${entity.publishedAt}, ${entity.createdAt}, ${entity.updatedAt}
)
`;
}
async update(entity: IndustrialListingEntity): Promise<void> {
await this.prisma.$executeRaw`
UPDATE "IndustrialListing" SET
"agentId" = ${entity.agentId},
"propertyType" = ${entity.propertyType}::"IndustrialPropertyType",
"leaseType" = ${entity.leaseType}::"IndustrialLeaseType",
status = ${entity.status}::"IndustrialListingStatus",
title = ${entity.title},
description = ${entity.description},
"areaM2" = ${entity.areaM2},
"ceilingHeightM" = ${entity.ceilingHeightM},
"floorLoadTonM2" = ${entity.floorLoadTonM2},
"columnSpacingM" = ${entity.columnSpacingM},
"dockCount" = ${entity.dockCount},
"craneCapacityTon" = ${entity.craneCapacityTon},
"hasMezzanine" = ${entity.hasMezzanine},
"hasOfficeArea" = ${entity.hasOfficeArea},
"officeAreaM2" = ${entity.officeAreaM2},
"priceUsdM2" = ${entity.priceUsdM2},
"pricingUnit" = ${entity.pricingUnit},
"totalLeasePrice" = ${entity.totalLeasePrice},
"managementFee" = ${entity.managementFee},
"depositMonths" = ${entity.depositMonths},
"minLeaseYears" = ${entity.minLeaseYears},
"maxLeaseYears" = ${entity.maxLeaseYears},
"leaseExpiry" = ${entity.leaseExpiry},
"availableFrom" = ${entity.availableFrom},
"powerCapacityKva" = ${entity.powerCapacityKva},
"waterSupplyM3Day" = ${entity.waterSupplyM3Day},
media = ${entity.media ? JSON.stringify(entity.media) : null}::jsonb,
"updatedAt" = ${entity.updatedAt}
WHERE id = ${entity.id}
`;
}
async search(params: IndustrialListingSearchParams): Promise<PaginatedResult<IndustrialListingListItem>> {
const page = params.page ?? 1;
const limit = params.limit ?? 20;
const offset = (page - 1) * limit;
const conditions: string[] = ['1=1'];
const values: unknown[] = [];
let paramIndex = 1;
if (params.parkId) {
conditions.push(`l."parkId" = $${paramIndex++}`);
values.push(params.parkId);
}
if (params.propertyType) {
conditions.push(`l."propertyType" = $${paramIndex++}::"IndustrialPropertyType"`);
values.push(params.propertyType);
}
if (params.leaseType) {
conditions.push(`l."leaseType" = $${paramIndex++}::"IndustrialLeaseType"`);
values.push(params.leaseType);
}
if (params.status) {
conditions.push(`l.status = $${paramIndex++}::"IndustrialListingStatus"`);
values.push(params.status);
}
if (params.minAreaM2 != null) {
conditions.push(`l."areaM2" >= $${paramIndex++}`);
values.push(params.minAreaM2);
}
if (params.maxAreaM2 != null) {
conditions.push(`l."areaM2" <= $${paramIndex++}`);
values.push(params.maxAreaM2);
}
if (params.minPriceUsdM2 != null) {
conditions.push(`l."priceUsdM2" >= $${paramIndex++}`);
values.push(params.minPriceUsdM2);
}
if (params.maxPriceUsdM2 != null) {
conditions.push(`l."priceUsdM2" <= $${paramIndex++}`);
values.push(params.maxPriceUsdM2);
}
if (params.query) {
conditions.push(`(l.title ILIKE $${paramIndex} OR l.description ILIKE $${paramIndex})`);
values.push(`%${params.query}%`);
paramIndex++;
}
const where = conditions.join(' AND ');
const countResult = await this.prisma.$queryRawUnsafe<[{ count: bigint }]>(
`SELECT COUNT(*)::bigint as count FROM "IndustrialListing" l WHERE ${where}`,
...values,
);
const total = Number(countResult[0].count);
const rows = await this.prisma.$queryRawUnsafe<RawListingListItem[]>(
`SELECT l.id, l."parkId", p.name as "parkName", l."propertyType"::text,
l."leaseType"::text, l.status::text, l.title, l."areaM2",
l."priceUsdM2", l."pricingUnit", l."ceilingHeightM",
l."hasMezzanine", l."hasOfficeArea", l."publishedAt", l."createdAt"
FROM "IndustrialListing" l
JOIN "IndustrialPark" p ON p.id = l."parkId"
WHERE ${where}
ORDER BY l."createdAt" DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
...values, limit, offset,
);
return {
data: rows.map((r) => this.toListItem(r)),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
private toDomain(row: RawListing): IndustrialListingEntity {
return new IndustrialListingEntity(
row.id,
{
parkId: row.parkId,
agentId: row.agentId,
sellerId: row.sellerId,
propertyType: row.propertyType,
leaseType: row.leaseType,
status: row.status,
title: row.title,
description: row.description,
areaM2: row.areaM2,
ceilingHeightM: row.ceilingHeightM,
floorLoadTonM2: row.floorLoadTonM2,
columnSpacingM: row.columnSpacingM,
dockCount: row.dockCount,
craneCapacityTon: row.craneCapacityTon,
hasMezzanine: row.hasMezzanine,
hasOfficeArea: row.hasOfficeArea,
officeAreaM2: row.officeAreaM2,
priceUsdM2: row.priceUsdM2,
pricingUnit: row.pricingUnit,
totalLeasePrice: row.totalLeasePrice,
managementFee: row.managementFee,
depositMonths: row.depositMonths,
minLeaseYears: row.minLeaseYears,
maxLeaseYears: row.maxLeaseYears,
leaseExpiry: row.leaseExpiry,
availableFrom: row.availableFrom,
powerCapacityKva: row.powerCapacityKva,
waterSupplyM3Day: row.waterSupplyM3Day,
media: row.media as Record<string, unknown>[] | null,
viewCount: row.viewCount,
inquiryCount: row.inquiryCount,
publishedAt: row.publishedAt,
},
row.createdAt,
row.updatedAt,
);
}
private toListItem(row: RawListingListItem): IndustrialListingListItem {
return {
id: row.id,
parkId: row.parkId,
parkName: row.parkName,
propertyType: row.propertyType as IndustrialPropertyType,
leaseType: row.leaseType as IndustrialLeaseType,
status: row.status as IndustrialListingStatus,
title: row.title,
areaM2: row.areaM2,
priceUsdM2: row.priceUsdM2,
pricingUnit: row.pricingUnit,
ceilingHeightM: row.ceilingHeightM,
hasMezzanine: row.hasMezzanine,
hasOfficeArea: row.hasOfficeArea,
publishedAt: row.publishedAt,
createdAt: row.createdAt,
};
}
private toDetail(row: RawListingDetail): IndustrialListingDetailData {
return {
id: row.id,
parkId: row.parkId,
parkName: row.parkName,
parkSlug: row.parkSlug,
agentId: row.agentId,
sellerId: row.sellerId,
propertyType: row.propertyType,
leaseType: row.leaseType,
status: row.status,
title: row.title,
description: row.description,
areaM2: row.areaM2,
ceilingHeightM: row.ceilingHeightM,
floorLoadTonM2: row.floorLoadTonM2,
columnSpacingM: row.columnSpacingM,
dockCount: row.dockCount,
craneCapacityTon: row.craneCapacityTon,
hasMezzanine: row.hasMezzanine,
hasOfficeArea: row.hasOfficeArea,
officeAreaM2: row.officeAreaM2,
priceUsdM2: row.priceUsdM2,
pricingUnit: row.pricingUnit,
totalLeasePrice: row.totalLeasePrice,
managementFee: row.managementFee,
depositMonths: row.depositMonths,
minLeaseYears: row.minLeaseYears,
maxLeaseYears: row.maxLeaseYears,
leaseExpiry: row.leaseExpiry,
availableFrom: row.availableFrom,
powerCapacityKva: row.powerCapacityKva,
waterSupplyM3Day: row.waterSupplyM3Day,
media: row.media as Record<string, unknown>[] | null,
viewCount: row.viewCount,
inquiryCount: row.inquiryCount,
publishedAt: row.publishedAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
}
interface RawListing {
id: string;
parkId: string;
agentId: string | null;
sellerId: string;
propertyType: IndustrialPropertyType;
leaseType: IndustrialLeaseType;
status: IndustrialListingStatus;
title: string;
description: string | null;
areaM2: number;
ceilingHeightM: number | null;
floorLoadTonM2: number | null;
columnSpacingM: number | null;
dockCount: number | null;
craneCapacityTon: number | null;
hasMezzanine: boolean;
hasOfficeArea: boolean;
officeAreaM2: number | null;
priceUsdM2: number | null;
pricingUnit: string | null;
totalLeasePrice: number | null;
managementFee: number | null;
depositMonths: number | null;
minLeaseYears: number | null;
maxLeaseYears: number | null;
leaseExpiry: Date | null;
availableFrom: Date | null;
powerCapacityKva: number | null;
waterSupplyM3Day: number | null;
media: Prisma.JsonValue;
viewCount: number;
inquiryCount: number;
publishedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
interface RawListingListItem {
id: string;
parkId: string;
parkName: string;
propertyType: string;
leaseType: string;
status: string;
title: string;
areaM2: number;
priceUsdM2: number | null;
pricingUnit: string | null;
ceilingHeightM: number | null;
hasMezzanine: boolean;
hasOfficeArea: boolean;
publishedAt: Date | null;
createdAt: Date;
}
interface RawListingDetail extends RawListing {
parkName: string;
parkSlug: string;
}

View File

@@ -88,6 +88,28 @@ interface RawIndustrialPark {
createdAt: Date;
}
interface RawIndustrialListing {
id: string;
title: string;
description: string | null;
parkId: string;
parkName: string;
propertyType: string;
leaseType: string;
province: string;
region: string;
areaM2: number;
priceUsdM2: number | null;
ceilingHeightM: number | null;
floorLoadTonM2: number | null;
targetIndustries: string[] | null;
lat: number;
lng: number;
occupancyRate: number;
status: string;
publishedAt: Date | null;
}
@Injectable()
export class TypesenseIndustrialService implements OnModuleInit {
private client: TypesenseClient | null = null;
@@ -103,6 +125,7 @@ export class TypesenseIndustrialService implements OnModuleInit {
this.client = this.typesenseClient.getClient();
await this.ensureCollections();
await this.syncParks();
await this.syncListings();
} catch (err) {
this.logger.warn(`Typesense industrial init failed (non-fatal): ${err}`, 'TypesenseIndustrial');
}
@@ -172,6 +195,105 @@ export class TypesenseIndustrialService implements OnModuleInit {
}
}
async syncListings(): Promise<void> {
if (!this.client) return;
const listings = await this.prisma.$queryRaw<RawIndustrialListing[]>`
SELECT l.id, l.title, l.description, l."parkId", p.name as "parkName",
l."propertyType"::text, l."leaseType"::text, p.province, p.region::text,
l."areaM2", l."priceUsdM2", l."ceilingHeightM", l."floorLoadTonM2",
p."targetIndustries",
ST_Y(p.location::geometry) as lat, ST_X(p.location::geometry) as lng,
p."occupancyRate", l.status::text, l."publishedAt"
FROM "IndustrialListing" l
JOIN "IndustrialPark" p ON p.id = l."parkId"
WHERE l.status != 'EXPIRED'
`;
if (listings.length === 0) return;
const docs = listings.map((l) => ({
id: l.id,
listingId: l.id,
title: l.title,
description: l.description ?? undefined,
parkName: l.parkName,
parkId: l.parkId,
propertyType: l.propertyType.toLowerCase(),
leaseType: l.leaseType.toLowerCase(),
province: l.province,
region: l.region.toLowerCase(),
areaM2: l.areaM2,
priceUsdM2: l.priceUsdM2 ?? undefined,
ceilingHeightM: l.ceilingHeightM ?? undefined,
floorLoadTonM2: l.floorLoadTonM2 ?? undefined,
targetIndustries: l.targetIndustries ?? [],
location: [Number(l.lat), Number(l.lng)],
occupancyRate: l.occupancyRate,
status: l.status.toLowerCase(),
publishedAt: l.publishedAt ? Math.floor(l.publishedAt.getTime() / 1000) : undefined,
}));
try {
const jsonl = docs.map((d) => JSON.stringify(d)).join('\n');
await this.client.collections(INDUSTRIAL_LISTINGS_COLLECTION).documents().import(jsonl, { action: 'upsert' });
this.logger.log(`Synced ${docs.length} listings to Typesense`, 'TypesenseIndustrial');
} catch (err) {
this.logger.warn(`Listing sync error: ${err}`, 'TypesenseIndustrial');
}
}
async indexListing(listingId: string): Promise<void> {
if (!this.client) return;
const [listing] = await this.prisma.$queryRaw<RawIndustrialListing[]>`
SELECT l.id, l.title, l.description, l."parkId", p.name as "parkName",
l."propertyType"::text, l."leaseType"::text, p.province, p.region::text,
l."areaM2", l."priceUsdM2", l."ceilingHeightM", l."floorLoadTonM2",
p."targetIndustries",
ST_Y(p.location::geometry) as lat, ST_X(p.location::geometry) as lng,
p."occupancyRate", l.status::text, l."publishedAt"
FROM "IndustrialListing" l
JOIN "IndustrialPark" p ON p.id = l."parkId"
WHERE l.id = ${listingId}
`;
if (!listing) return;
const doc = {
id: listing.id,
listingId: listing.id,
title: listing.title,
description: listing.description ?? undefined,
parkName: listing.parkName,
parkId: listing.parkId,
propertyType: listing.propertyType.toLowerCase(),
leaseType: listing.leaseType.toLowerCase(),
province: listing.province,
region: listing.region.toLowerCase(),
areaM2: listing.areaM2,
priceUsdM2: listing.priceUsdM2 ?? undefined,
ceilingHeightM: listing.ceilingHeightM ?? undefined,
floorLoadTonM2: listing.floorLoadTonM2 ?? undefined,
targetIndustries: listing.targetIndustries ?? [],
location: [Number(listing.lat), Number(listing.lng)],
occupancyRate: listing.occupancyRate,
status: listing.status.toLowerCase(),
publishedAt: listing.publishedAt ? Math.floor(listing.publishedAt.getTime() / 1000) : undefined,
};
await this.client.collections(INDUSTRIAL_LISTINGS_COLLECTION).documents().upsert(doc);
}
async deleteListing(listingId: string): Promise<void> {
if (!this.client) return;
try {
await this.client.collections(INDUSTRIAL_LISTINGS_COLLECTION).documents(listingId).delete();
} catch {
// Document may not exist in Typesense
}
}
async indexPark(parkId: string): Promise<void> {
if (!this.client) return;

View File

@@ -0,0 +1,147 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CurrentUser, JwtAuthGuard, type JwtPayload } from '@modules/auth';
import { NotFoundException } from '@modules/shared';
import { CreateIndustrialListingCommand } from '../../application/commands/create-industrial-listing/create-industrial-listing.command';
import { DeleteIndustrialListingCommand } from '../../application/commands/delete-industrial-listing/delete-industrial-listing.command';
import { UpdateIndustrialListingCommand } from '../../application/commands/update-industrial-listing/update-industrial-listing.command';
import { GetIndustrialListingQuery } from '../../application/queries/get-industrial-listing/get-industrial-listing.query';
import { ListIndustrialListingsQuery } from '../../application/queries/list-industrial-listings/list-industrial-listings.query';
import { type CreateIndustrialListingDto } from '../dto/create-industrial-listing.dto';
import { type SearchIndustrialListingsDto } from '../dto/search-industrial-listings.dto';
import { type UpdateIndustrialListingDto } from '../dto/update-industrial-listing.dto';
@ApiTags('industrial-listings')
@Controller('industrial')
export class IndustrialListingsController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
// ── Public endpoints ──────────────────────────────────────────────
@ApiOperation({ summary: 'Danh sách BĐS công nghiệp', description: 'Tìm kiếm và lọc tin đăng BĐS công nghiệp' })
@ApiResponse({ status: 200, description: 'Danh sách tin đăng phân trang' })
@Get('listings')
async listListings(@Query() dto: SearchIndustrialListingsDto) {
return this.queryBus.execute(
new ListIndustrialListingsQuery(
dto.parkId,
dto.propertyType,
dto.leaseType,
dto.status,
dto.minAreaM2,
dto.maxAreaM2,
dto.minPriceUsdM2,
dto.maxPriceUsdM2,
dto.q,
dto.page ?? 1,
dto.limit ?? 20,
),
);
}
@ApiOperation({ summary: 'Chi tiết tin đăng', description: 'Xem chi tiết tin đăng BĐS công nghiệp' })
@ApiResponse({ status: 200, description: 'Thông tin chi tiết tin đăng' })
@ApiResponse({ status: 404, description: 'Không tìm thấy tin đăng' })
@Get('listings/:id')
async getListing(@Param('id') id: string) {
const result = await this.queryBus.execute(new GetIndustrialListingQuery(id));
if (!result) {
throw new NotFoundException('Industrial listing', id);
}
return result;
}
// ── Authenticated endpoints ───────────────────────────────────────
@ApiOperation({ summary: 'Tạo tin đăng', description: 'Tạo mới tin đăng BĐS công nghiệp' })
@ApiResponse({ status: 201, description: 'Tin đăng đã tạo' })
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard)
@Post('listings')
async createListing(@Body() dto: CreateIndustrialListingDto, @CurrentUser() user: JwtPayload) {
return this.commandBus.execute(
new CreateIndustrialListingCommand(
dto.parkId,
user.sub,
dto.agentId ?? null,
dto.propertyType,
dto.leaseType,
dto.title,
dto.description ?? null,
dto.areaM2,
dto.ceilingHeightM ?? null,
dto.floorLoadTonM2 ?? null,
dto.columnSpacingM ?? null,
dto.dockCount ?? null,
dto.craneCapacityTon ?? null,
dto.hasMezzanine ?? false,
dto.hasOfficeArea ?? false,
dto.officeAreaM2 ?? null,
dto.priceUsdM2 ?? null,
dto.pricingUnit ?? null,
dto.totalLeasePrice ?? null,
dto.managementFee ?? null,
dto.depositMonths ?? null,
dto.minLeaseYears ?? null,
dto.maxLeaseYears ?? null,
dto.leaseExpiry ? new Date(dto.leaseExpiry) : null,
dto.availableFrom ? new Date(dto.availableFrom) : null,
dto.powerCapacityKva ?? null,
dto.waterSupplyM3Day ?? null,
dto.media ?? null,
),
);
}
@ApiOperation({ summary: 'Cập nhật tin đăng', description: 'Cập nhật thông tin tin đăng BĐS công nghiệp' })
@ApiResponse({ status: 200, description: 'Tin đăng đã cập nhật' })
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard)
@Patch('listings/:id')
async updateListing(@Param('id') id: string, @Body() dto: UpdateIndustrialListingDto) {
return this.commandBus.execute(
new UpdateIndustrialListingCommand(
id,
dto.propertyType,
dto.leaseType,
dto.status,
dto.title,
dto.description,
dto.areaM2,
dto.ceilingHeightM,
dto.floorLoadTonM2,
dto.columnSpacingM,
dto.dockCount,
dto.craneCapacityTon,
dto.hasMezzanine,
dto.hasOfficeArea,
dto.officeAreaM2,
dto.priceUsdM2,
dto.pricingUnit,
dto.totalLeasePrice,
dto.managementFee,
dto.depositMonths,
dto.minLeaseYears,
dto.maxLeaseYears,
dto.leaseExpiry ? new Date(dto.leaseExpiry) : dto.leaseExpiry as undefined,
dto.availableFrom ? new Date(dto.availableFrom) : dto.availableFrom as undefined,
dto.powerCapacityKva,
dto.waterSupplyM3Day,
dto.media,
),
);
}
@ApiOperation({ summary: 'Xóa tin đăng', description: 'Xóa mềm tin đăng BĐS công nghiệp' })
@ApiResponse({ status: 200, description: 'Tin đăng đã xóa' })
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard)
@Delete('listings/:id')
async deleteListing(@Param('id') id: string) {
return this.commandBus.execute(new DeleteIndustrialListingCommand(id));
}
}

View File

@@ -4,15 +4,19 @@ import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagg
import { UserRole } from '@prisma/client';
import { JwtAuthGuard, Roles, RolesGuard } from '@modules/auth';
import { NotFoundException } from '@modules/shared';
import { AnalyzeIndustrialLocationQuery } from '../../application/queries/analyze-industrial-location/analyze-industrial-location.query';
import { CreateIndustrialParkCommand } from '../../application/commands/create-industrial-park/create-industrial-park.command';
import { EstimateIndustrialRentQuery } from '../../application/queries/estimate-industrial-rent/estimate-industrial-rent.query';
import { UpdateIndustrialParkCommand } from '../../application/commands/update-industrial-park/update-industrial-park.command';
import { CompareIndustrialParksQuery } from '../../application/queries/compare-industrial-parks/compare-industrial-parks.query';
import { GetIndustrialParkQuery } from '../../application/queries/get-industrial-park/get-industrial-park.query';
import { IndustrialMarketQuery } from '../../application/queries/industrial-market/industrial-market.query';
import { IndustrialParkStatsQuery } from '../../application/queries/industrial-park-stats/industrial-park-stats.query';
import { ListIndustrialParksQuery } from '../../application/queries/list-industrial-parks/list-industrial-parks.query';
import { type AnalyzeIndustrialLocationDto } from '../dto/analyze-industrial-location.dto';
import { type CompareIndustrialParksDto } from '../dto/compare-industrial-parks.dto';
import { type CreateIndustrialParkDto } from '../dto/create-industrial-park.dto';
import { type EstimateIndustrialRentDto } from '../dto/estimate-industrial-rent.dto';
import { type SearchIndustrialParksDto } from '../dto/search-industrial-parks.dto';
import { type UpdateIndustrialParkDto } from '../dto/update-industrial-park.dto';
@@ -78,6 +82,38 @@ export class IndustrialParksController {
return this.queryBus.execute(new IndustrialMarketQuery());
}
@ApiOperation({ summary: 'Phân tích vị trí KCN', description: 'Đánh giá vị trí dựa trên hạ tầng, kết nối, lao động' })
@ApiResponse({ status: 200, description: 'Kết quả phân tích vị trí' })
@Post('analyze-location')
async analyzeLocation(@Body() dto: AnalyzeIndustrialLocationDto) {
return this.queryBus.execute(
new AnalyzeIndustrialLocationQuery(
dto.latitude,
dto.longitude,
dto.park_name ?? null,
dto.target_industry ?? null,
),
);
}
@ApiOperation({ summary: 'Ước tính giá thuê KCN', description: 'Tính giá thuê BĐS công nghiệp theo tỉnh, loại, diện tích' })
@ApiResponse({ status: 200, description: 'Kết quả ước tính giá thuê' })
@Post('estimate-rent')
async estimateRent(@Body() dto: EstimateIndustrialRentDto) {
return this.queryBus.execute(
new EstimateIndustrialRentQuery(
dto.province,
dto.property_type,
dto.area_m2,
dto.lease_duration_years,
dto.park_name ?? null,
dto.requires_crane ?? false,
dto.required_power_kva ?? null,
dto.requires_wastewater ?? false,
),
);
}
// ── Admin endpoints ───────────────────────────────────────────────
@ApiOperation({ summary: 'Tạo KCN (admin)', description: 'Tạo mới khu công nghiệp' })

View File

@@ -0,0 +1,29 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
export class AnalyzeIndustrialLocationDto {
@ApiProperty({ example: 10.9, description: 'Vĩ độ' })
@IsNumber()
@Type(() => Number)
@Min(-90)
@Max(90)
latitude!: number;
@ApiProperty({ example: 106.8, description: 'Kinh độ' })
@IsNumber()
@Type(() => Number)
@Min(-180)
@Max(180)
longitude!: number;
@ApiPropertyOptional({ example: 'VSIP Bình Dương', description: 'Tên KCN cần phân tích' })
@IsOptional()
@IsString()
park_name?: string;
@ApiPropertyOptional({ example: 'electronics', description: 'Ngành mục tiêu' })
@IsOptional()
@IsString()
target_industry?: string;
}

View File

@@ -0,0 +1,165 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IndustrialLeaseType, IndustrialPropertyType } from '@prisma/client';
import { Type } from 'class-transformer';
import {
IsString,
IsNumber,
IsEnum,
IsOptional,
IsBoolean,
IsArray,
IsObject,
IsDateString,
Min,
MaxLength,
} from 'class-validator';
export class CreateIndustrialListingDto {
@ApiProperty({ description: 'ID khu công nghiệp' })
@IsString()
parkId!: string;
@ApiPropertyOptional({ description: 'ID môi giới' })
@IsOptional()
@IsString()
agentId?: string;
@ApiProperty({ enum: IndustrialPropertyType, example: 'READY_BUILT_FACTORY' })
@IsEnum(IndustrialPropertyType)
propertyType!: IndustrialPropertyType;
@ApiProperty({ enum: IndustrialLeaseType, example: 'FACTORY_LEASE' })
@IsEnum(IndustrialLeaseType)
leaseType!: IndustrialLeaseType;
@ApiProperty({ example: 'Nhà xưởng 5000m² tại KCN VSIP', description: 'Tiêu đề' })
@IsString()
@MaxLength(300)
title!: string;
@ApiPropertyOptional({ description: 'Mô tả chi tiết' })
@IsOptional()
@IsString()
description?: string;
@ApiProperty({ example: 5000, description: 'Diện tích (m²)' })
@IsNumber()
@Type(() => Number)
@Min(0)
areaM2!: number;
@ApiPropertyOptional({ example: 12, description: 'Chiều cao trần (m)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
ceilingHeightM?: number;
@ApiPropertyOptional({ example: 3, description: 'Tải trọng sàn (tấn/m²)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
floorLoadTonM2?: number;
@ApiPropertyOptional({ example: 12, description: 'Khoảng cách cột (m)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
columnSpacingM?: number;
@ApiPropertyOptional({ example: 4, description: 'Số dock' })
@IsOptional()
@IsNumber()
@Type(() => Number)
dockCount?: number;
@ApiPropertyOptional({ example: 10, description: 'Tải trọng cẩu trục (tấn)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
craneCapacityTon?: number;
@ApiPropertyOptional({ example: false })
@IsOptional()
@IsBoolean()
hasMezzanine?: boolean;
@ApiPropertyOptional({ example: true })
@IsOptional()
@IsBoolean()
hasOfficeArea?: boolean;
@ApiPropertyOptional({ example: 200, description: 'Diện tích văn phòng (m²)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
officeAreaM2?: number;
@ApiPropertyOptional({ example: 5.5, description: 'Giá thuê (USD/m²)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
priceUsdM2?: number;
@ApiPropertyOptional({ example: 'usd/m2/month', description: 'Đơn vị giá' })
@IsOptional()
@IsString()
pricingUnit?: string;
@ApiPropertyOptional({ example: 27500, description: 'Tổng giá thuê' })
@IsOptional()
@IsNumber()
@Type(() => Number)
totalLeasePrice?: number;
@ApiPropertyOptional({ example: 0.6, description: 'Phí quản lý' })
@IsOptional()
@IsNumber()
@Type(() => Number)
managementFee?: number;
@ApiPropertyOptional({ example: 3, description: 'Số tháng đặt cọc' })
@IsOptional()
@IsNumber()
@Type(() => Number)
depositMonths?: number;
@ApiPropertyOptional({ example: 3, description: 'Thời hạn thuê tối thiểu (năm)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
minLeaseYears?: number;
@ApiPropertyOptional({ example: 50, description: 'Thời hạn thuê tối đa (năm)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
maxLeaseYears?: number;
@ApiPropertyOptional({ description: 'Ngày hết hạn thuê' })
@IsOptional()
@IsDateString()
leaseExpiry?: string;
@ApiPropertyOptional({ description: 'Ngày có thể bắt đầu thuê' })
@IsOptional()
@IsDateString()
availableFrom?: string;
@ApiPropertyOptional({ example: 500, description: 'Công suất điện (KVA)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
powerCapacityKva?: number;
@ApiPropertyOptional({ example: 100, description: 'Cấp nước (m³/ngày)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
waterSupplyM3Day?: number;
@ApiPropertyOptional({ description: 'Hình ảnh / tài liệu' })
@IsOptional()
@IsArray()
@IsObject({ each: true })
media?: Record<string, unknown>[];
}

View File

@@ -0,0 +1,62 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
const INDUSTRIAL_PROPERTY_TYPES = [
'industrial_land',
'ready_built_factory',
'ready_built_warehouse',
'logistics_center',
'office_in_park',
'data_center',
] as const;
export class EstimateIndustrialRentDto {
@ApiProperty({ example: 'Bình Dương', description: 'Tỉnh/thành phố' })
@IsString()
province!: string;
@ApiProperty({
example: 'ready_built_factory',
enum: INDUSTRIAL_PROPERTY_TYPES,
description: 'Loại BĐS công nghiệp',
})
@IsEnum(INDUSTRIAL_PROPERTY_TYPES)
property_type!: (typeof INDUSTRIAL_PROPERTY_TYPES)[number];
@ApiProperty({ example: 5000, description: 'Diện tích yêu cầu (m²)' })
@IsNumber()
@Type(() => Number)
@Min(1)
area_m2!: number;
@ApiProperty({ example: 10, description: 'Thời hạn thuê (năm)' })
@IsNumber()
@Type(() => Number)
@Min(1)
@Max(70)
lease_duration_years!: number;
@ApiPropertyOptional({ example: 'VSIP Bình Dương', description: 'Tên KCN cụ thể' })
@IsOptional()
@IsString()
park_name?: string;
@ApiPropertyOptional({ example: false, description: 'Yêu cầu cầu trục' })
@IsOptional()
@IsBoolean()
@Type(() => Boolean)
requires_crane?: boolean;
@ApiPropertyOptional({ example: 500, description: 'Công suất điện yêu cầu (KVA)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
required_power_kva?: number;
@ApiPropertyOptional({ example: false, description: 'Yêu cầu xử lý nước thải' })
@IsOptional()
@IsBoolean()
@Type(() => Boolean)
requires_wastewater?: boolean;
}

View File

@@ -0,0 +1,71 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IndustrialLeaseType, IndustrialListingStatus, IndustrialPropertyType } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
export class SearchIndustrialListingsDto {
@ApiPropertyOptional({ description: 'Lọc theo KCN' })
@IsOptional()
@IsString()
parkId?: string;
@ApiPropertyOptional({ enum: IndustrialPropertyType })
@IsOptional()
@IsEnum(IndustrialPropertyType)
propertyType?: IndustrialPropertyType;
@ApiPropertyOptional({ enum: IndustrialLeaseType })
@IsOptional()
@IsEnum(IndustrialLeaseType)
leaseType?: IndustrialLeaseType;
@ApiPropertyOptional({ enum: IndustrialListingStatus })
@IsOptional()
@IsEnum(IndustrialListingStatus)
status?: IndustrialListingStatus;
@ApiPropertyOptional({ example: 1000, description: 'Diện tích tối thiểu (m²)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(0)
minAreaM2?: number;
@ApiPropertyOptional({ example: 10000, description: 'Diện tích tối đa (m²)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
maxAreaM2?: number;
@ApiPropertyOptional({ example: 3, description: 'Giá tối thiểu (USD/m²)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
minPriceUsdM2?: number;
@ApiPropertyOptional({ example: 10, description: 'Giá tối đa (USD/m²)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
maxPriceUsdM2?: number;
@ApiPropertyOptional({ example: 'nhà xưởng', description: 'Từ khóa tìm kiếm' })
@IsOptional()
@IsString()
q?: string;
@ApiPropertyOptional({ example: 1, default: 1 })
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(1)
page?: number;
@ApiPropertyOptional({ example: 20, default: 20 })
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(1)
@Max(100)
limit?: number;
}

View File

@@ -0,0 +1,165 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IndustrialLeaseType, IndustrialListingStatus, IndustrialPropertyType } from '@prisma/client';
import { Type } from 'class-transformer';
import {
IsString,
IsNumber,
IsEnum,
IsOptional,
IsBoolean,
IsArray,
IsObject,
IsDateString,
Min,
MaxLength,
} from 'class-validator';
export class UpdateIndustrialListingDto {
@ApiPropertyOptional({ enum: IndustrialPropertyType })
@IsOptional()
@IsEnum(IndustrialPropertyType)
propertyType?: IndustrialPropertyType;
@ApiPropertyOptional({ enum: IndustrialLeaseType })
@IsOptional()
@IsEnum(IndustrialLeaseType)
leaseType?: IndustrialLeaseType;
@ApiPropertyOptional({ enum: IndustrialListingStatus })
@IsOptional()
@IsEnum(IndustrialListingStatus)
status?: IndustrialListingStatus;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(300)
title?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(0)
areaM2?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
ceilingHeightM?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
floorLoadTonM2?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
columnSpacingM?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
dockCount?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
craneCapacityTon?: number;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
hasMezzanine?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
hasOfficeArea?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
officeAreaM2?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
priceUsdM2?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
pricingUnit?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
totalLeasePrice?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
managementFee?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
depositMonths?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
minLeaseYears?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
maxLeaseYears?: number;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
leaseExpiry?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
availableFrom?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
powerCapacityKva?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
waterSupplyM3Day?: number;
@ApiPropertyOptional()
@IsOptional()
@IsArray()
@IsObject({ each: true })
media?: Record<string, unknown>[];
}

View File

@@ -0,0 +1,113 @@
import { ActivateFeaturedListingHandler } from '../event-handlers/activate-featured-listing.handler';
describe('ActivateFeaturedListingHandler', () => {
let handler: ActivateFeaturedListingHandler;
let mockPrisma: {
payment: { findUnique: ReturnType<typeof vi.fn> };
listing: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
};
let mockLogger: { log: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockPrisma = {
payment: { findUnique: vi.fn() },
listing: { findUnique: vi.fn(), update: vi.fn() },
};
mockLogger = { log: vi.fn() };
handler = new ActivateFeaturedListingHandler(
mockPrisma as any,
mockLogger as any,
);
});
it('activates featured listing for 7 days on 199000 VND payment', async () => {
mockPrisma.payment.findUnique.mockResolvedValue({
type: 'FEATURED_LISTING',
transactionId: 'listing-1',
amountVND: 199000n,
});
mockPrisma.listing.findUnique.mockResolvedValue({ featuredUntil: null });
mockPrisma.listing.update.mockResolvedValue({});
await handler.handle({ aggregateId: 'pay-1' } as any);
expect(mockPrisma.listing.update).toHaveBeenCalledWith({
where: { id: 'listing-1' },
data: { featuredUntil: expect.any(Date) },
});
const updateCall = mockPrisma.listing.update.mock.calls[0][0];
const featuredUntil = updateCall.data.featuredUntil as Date;
const diffDays = Math.round((featuredUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
expect(diffDays).toBe(7);
});
it('activates featured listing for 3 days on 99000 VND payment', async () => {
mockPrisma.payment.findUnique.mockResolvedValue({
type: 'FEATURED_LISTING',
transactionId: 'listing-1',
amountVND: 99000n,
});
mockPrisma.listing.findUnique.mockResolvedValue({ featuredUntil: null });
mockPrisma.listing.update.mockResolvedValue({});
await handler.handle({ aggregateId: 'pay-1' } as any);
const updateCall = mockPrisma.listing.update.mock.calls[0][0];
const featuredUntil = updateCall.data.featuredUntil as Date;
const diffDays = Math.round((featuredUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
expect(diffDays).toBe(3);
});
it('extends from existing featuredUntil if still in the future', async () => {
const futureDate = new Date(Date.now() + 5 * 24 * 60 * 60 * 1000); // 5 days from now
mockPrisma.payment.findUnique.mockResolvedValue({
type: 'FEATURED_LISTING',
transactionId: 'listing-1',
amountVND: 199000n,
});
mockPrisma.listing.findUnique.mockResolvedValue({ featuredUntil: futureDate });
mockPrisma.listing.update.mockResolvedValue({});
await handler.handle({ aggregateId: 'pay-1' } as any);
const updateCall = mockPrisma.listing.update.mock.calls[0][0];
const featuredUntil = updateCall.data.featuredUntil as Date;
// Should extend from futureDate (5 days out) + 7 days = ~12 days from now
const diffDays = Math.round((featuredUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
expect(diffDays).toBe(12);
});
it('ignores non-FEATURED_LISTING payments', async () => {
mockPrisma.payment.findUnique.mockResolvedValue({
type: 'SUBSCRIPTION',
transactionId: 'listing-1',
amountVND: 199000n,
});
await handler.handle({ aggregateId: 'pay-1' } as any);
expect(mockPrisma.listing.update).not.toHaveBeenCalled();
});
it('ignores payments without transactionId', async () => {
mockPrisma.payment.findUnique.mockResolvedValue({
type: 'FEATURED_LISTING',
transactionId: null,
amountVND: 199000n,
});
await handler.handle({ aggregateId: 'pay-1' } as any);
expect(mockPrisma.listing.update).not.toHaveBeenCalled();
});
it('ignores payments that do not exist', async () => {
mockPrisma.payment.findUnique.mockResolvedValue(null);
await handler.handle({ aggregateId: 'pay-1' } as any);
expect(mockPrisma.listing.update).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,131 @@
import { ListingEntity } from '@modules/listings/domain/entities/listing.entity';
import { Price } from '@modules/listings/domain/value-objects/price.vo';
import { AdminFeatureListingCommand } from '../commands/admin-feature-listing/admin-feature-listing.command';
import { AdminFeatureListingHandler } from '../commands/admin-feature-listing/admin-feature-listing.handler';
function createListing(
id = 'listing-1',
status: 'DRAFT' | 'PENDING_REVIEW' | 'ACTIVE' = 'ACTIVE',
): ListingEntity {
const price = Price.create(1_500_000_000n).unwrap();
const listing = ListingEntity.createNew(id, 'prop-1', 'seller-1', 'SALE', price, 60);
if (status === 'PENDING_REVIEW' || status === 'ACTIVE') listing.submitForReview();
if (status === 'ACTIVE') listing.approve();
listing.clearDomainEvents();
return listing;
}
describe('AdminFeatureListingHandler', () => {
let handler: AdminFeatureListingHandler;
let mockListingRepo: { findById: ReturnType<typeof vi.fn> };
let mockPrisma: {
$transaction: ReturnType<typeof vi.fn>;
listing: { update: ReturnType<typeof vi.fn> };
adminAuditLog: { create: ReturnType<typeof vi.fn> };
};
let mockLogger: { log: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
let transactionOps: unknown[];
beforeEach(() => {
transactionOps = [];
mockListingRepo = { findById: vi.fn() };
const listingUpdate = vi.fn().mockImplementation((args: unknown) => {
transactionOps.push({ kind: 'listing.update', args });
return { kind: 'listing.update', args };
});
const auditLogCreate = vi.fn().mockImplementation((args: unknown) => {
transactionOps.push({ kind: 'audit.create', args });
return { kind: 'audit.create', args };
});
const $transaction = vi.fn().mockImplementation(async (ops: unknown[]) => ops);
mockPrisma = {
$transaction,
listing: { update: listingUpdate },
adminAuditLog: { create: auditLogCreate },
};
mockLogger = { log: vi.fn(), error: vi.fn() };
handler = new AdminFeatureListingHandler(mockListingRepo as any, mockPrisma as any, mockLogger as any);
});
it('features a listing with durationDays and writes audit log', async () => {
mockListingRepo.findById.mockResolvedValue(createListing('listing-1', 'ACTIVE'));
const before = Date.now();
const result = await handler.execute(
new AdminFeatureListingCommand('listing-1', 'admin-1', 'feature', 14, 'Đền bù lỗi hiển thị', '10.0.0.1'),
);
const after = Date.now();
expect(result.action).toBe('feature');
expect(result.listingId).toBe('listing-1');
expect(result.featuredUntil).not.toBeNull();
const parsed = Date.parse(result.featuredUntil!);
expect(parsed).toBeGreaterThanOrEqual(before + 14 * 24 * 60 * 60 * 1000);
expect(parsed).toBeLessThanOrEqual(after + 14 * 24 * 60 * 60 * 1000 + 1000);
expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1);
const auditOp = transactionOps.find((op: any) => op.kind === 'audit.create') as any;
expect(auditOp.args.data.action).toBe('LISTING_FEATURED');
expect(auditOp.args.data.actorId).toBe('admin-1');
expect(auditOp.args.data.targetId).toBe('listing-1');
expect(auditOp.args.data.targetType).toBe('LISTING');
expect(auditOp.args.data.metadata.reason).toBe('Đền bù lỗi hiển thị');
expect(auditOp.args.data.metadata.durationDays).toBe(14);
expect(auditOp.args.data.ipAddress).toBe('10.0.0.1');
});
it('unfeatures a listing and logs LISTING_UNFEATURED', async () => {
mockListingRepo.findById.mockResolvedValue(createListing('listing-1', 'ACTIVE'));
const result = await handler.execute(
new AdminFeatureListingCommand('listing-1', 'admin-1', 'unfeature', null, 'Vi phạm chính sách nội dung', null),
);
expect(result.action).toBe('unfeature');
expect(result.featuredUntil).toBeNull();
const updateOp = transactionOps.find((op: any) => op.kind === 'listing.update') as any;
expect(updateOp.args.data.featuredUntil).toBeNull();
const auditOp = transactionOps.find((op: any) => op.kind === 'audit.create') as any;
expect(auditOp.args.data.action).toBe('LISTING_UNFEATURED');
expect(auditOp.args.data.metadata.featuredUntil).toBeNull();
});
it('rejects short reason', async () => {
mockListingRepo.findById.mockResolvedValue(createListing('listing-1', 'ACTIVE'));
await expect(
handler.execute(new AdminFeatureListingCommand('listing-1', 'admin-1', 'feature', 7, 'bad', null)),
).rejects.toThrow(/Lý do/);
expect(mockPrisma.$transaction).not.toHaveBeenCalled();
});
it('rejects feature action with invalid durationDays', async () => {
mockListingRepo.findById.mockResolvedValue(createListing('listing-1', 'ACTIVE'));
await expect(
handler.execute(new AdminFeatureListingCommand('listing-1', 'admin-1', 'feature', 5, 'reason long enough', null)),
).rejects.toThrow(/Thời lượng/);
});
it('rejects feature action with null durationDays', async () => {
mockListingRepo.findById.mockResolvedValue(createListing('listing-1', 'ACTIVE'));
await expect(
handler.execute(
new AdminFeatureListingCommand('listing-1', 'admin-1', 'feature', null, 'reason long enough', null),
),
).rejects.toThrow(/Thời lượng/);
});
it('throws NotFoundException for non-existent listing', async () => {
mockListingRepo.findById.mockResolvedValue(null);
await expect(
handler.execute(new AdminFeatureListingCommand('missing', 'admin-1', 'feature', 7, 'reason long enough', null)),
).rejects.toThrow('Listing');
});
});

View File

@@ -0,0 +1,128 @@
import { ListingEntity } from '@modules/listings/domain/entities/listing.entity';
import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
import { Price } from '@modules/listings/domain/value-objects/price.vo';
import { FeatureListingCommand } from '../commands/feature-listing/feature-listing.command';
import { FeatureListingHandler } from '../commands/feature-listing/feature-listing.handler';
function createListing(
id = 'listing-1',
sellerId = 'seller-1',
agentId: string | null = null,
status: 'DRAFT' | 'PENDING_REVIEW' | 'ACTIVE' = 'ACTIVE',
): ListingEntity {
const price = Price.create(2_000_000_000n).unwrap();
const listing = ListingEntity.createNew(id, 'prop-1', sellerId, 'SALE', price, 80, agentId ?? undefined);
if (status === 'PENDING_REVIEW' || status === 'ACTIVE') listing.submitForReview();
if (status === 'ACTIVE') listing.approve();
listing.clearDomainEvents();
return listing;
}
describe('FeatureListingHandler', () => {
let handler: FeatureListingHandler;
let mockListingRepo: Pick<IListingRepository, 'findById'>;
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
let mockLogger: { log: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockListingRepo = { findById: vi.fn() };
mockCommandBus = {
execute: vi.fn().mockResolvedValue({
paymentId: 'pay-1',
paymentUrl: 'https://pay.example.com/checkout',
providerTxId: 'tx-1',
}),
};
mockLogger = { log: vi.fn(), error: vi.fn() };
handler = new FeatureListingHandler(
mockListingRepo as any,
mockCommandBus as any,
mockLogger as any,
);
});
it('creates payment for a valid feature request', async () => {
const listing = createListing('listing-1', 'seller-1', null, 'ACTIVE');
(mockListingRepo.findById as ReturnType<typeof vi.fn>).mockResolvedValue(listing);
const command = new FeatureListingCommand(
'listing-1', 'seller-1', '7_days', 'VNPAY',
'https://goodgo.vn/callback', '127.0.0.1',
);
const result = await handler.execute(command);
expect(result.paymentId).toBe('pay-1');
expect(result.paymentUrl).toBe('https://pay.example.com/checkout');
expect(result.package_).toBe('7_days');
expect(result.priceVND).toBe('199000');
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('allows the assigned agent to feature the listing', async () => {
const listing = createListing('listing-1', 'seller-1', 'agent-1', 'ACTIVE');
(mockListingRepo.findById as ReturnType<typeof vi.fn>).mockResolvedValue(listing);
const command = new FeatureListingCommand(
'listing-1', 'agent-1', '3_days', 'MOMO',
'https://goodgo.vn/callback', '127.0.0.1',
);
const result = await handler.execute(command);
expect(result.paymentId).toBe('pay-1');
expect(result.priceVND).toBe('99000');
});
it('rejects feature request from unauthorized user', async () => {
const listing = createListing('listing-1', 'seller-1', null, 'ACTIVE');
(mockListingRepo.findById as ReturnType<typeof vi.fn>).mockResolvedValue(listing);
const command = new FeatureListingCommand(
'listing-1', 'stranger', '7_days', 'VNPAY',
'https://goodgo.vn/callback', '127.0.0.1',
);
await expect(handler.execute(command)).rejects.toThrow(/người bán|môi giới/);
});
it('rejects feature request for non-ACTIVE listing', async () => {
const listing = createListing('listing-1', 'seller-1', null, 'DRAFT');
(mockListingRepo.findById as ReturnType<typeof vi.fn>).mockResolvedValue(listing);
const command = new FeatureListingCommand(
'listing-1', 'seller-1', '7_days', 'VNPAY',
'https://goodgo.vn/callback', '127.0.0.1',
);
await expect(handler.execute(command)).rejects.toThrow(/hoạt động/);
});
it('throws NotFoundException for non-existent listing', async () => {
(mockListingRepo.findById as ReturnType<typeof vi.fn>).mockResolvedValue(null);
const command = new FeatureListingCommand(
'nonexistent', 'seller-1', '7_days', 'VNPAY',
'https://goodgo.vn/callback', '127.0.0.1',
);
await expect(handler.execute(command)).rejects.toThrow('Listing');
});
it('uses correct pricing for each package', async () => {
const listing = createListing('listing-1', 'seller-1', null, 'ACTIVE');
(mockListingRepo.findById as ReturnType<typeof vi.fn>).mockResolvedValue(listing);
for (const [pkg, expectedPrice] of [
['3_days', '99000'],
['7_days', '199000'],
['30_days', '499000'],
] as const) {
const command = new FeatureListingCommand(
'listing-1', 'seller-1', pkg, 'VNPAY',
'https://goodgo.vn/callback', '127.0.0.1',
);
const result = await handler.execute(command);
expect(result.priceVND).toBe(expectedPrice);
}
});
});

View File

@@ -0,0 +1,58 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GetPriceHistoryHandler } from '../queries/get-price-history/get-price-history.handler';
import { GetPriceHistoryQuery } from '../queries/get-price-history/get-price-history.query';
describe('GetPriceHistoryHandler', () => {
let handler: GetPriceHistoryHandler;
let mockPrisma: { priceHistory: { findMany: ReturnType<typeof vi.fn> } };
beforeEach(() => {
mockPrisma = {
priceHistory: { findMany: vi.fn() },
};
handler = new GetPriceHistoryHandler(mockPrisma as any);
});
it('should query price history for the given listing ordered by changedAt desc', async () => {
const mockHistory = [
{ id: 'ph-2', oldPrice: 5_000_000_000n, newPrice: 6_000_000_000n, source: 'manual_update', changedAt: new Date('2026-04-16') },
{ id: 'ph-1', oldPrice: 4_000_000_000n, newPrice: 5_000_000_000n, source: 'manual_update', changedAt: new Date('2026-04-10') },
];
mockPrisma.priceHistory.findMany.mockResolvedValue(mockHistory);
const query = new GetPriceHistoryQuery('listing-1');
const result = await handler.execute(query);
expect(result).toEqual(mockHistory);
expect(mockPrisma.priceHistory.findMany).toHaveBeenCalledWith({
where: { listingId: 'listing-1' },
orderBy: { changedAt: 'desc' },
select: {
id: true,
oldPrice: true,
newPrice: true,
source: true,
changedAt: true,
},
});
});
it('should return empty array when no history exists', async () => {
mockPrisma.priceHistory.findMany.mockResolvedValue([]);
const query = new GetPriceHistoryQuery('listing-no-history');
const result = await handler.execute(query);
expect(result).toEqual([]);
});
it('should include source field in the select', async () => {
mockPrisma.priceHistory.findMany.mockResolvedValue([
{ id: 'ph-1', oldPrice: 1n, newPrice: 2n, source: 'admin_override', changedAt: new Date() },
]);
const result = await handler.execute(new GetPriceHistoryQuery('listing-1'));
expect(result[0].source).toBe('admin_override');
});
});

View File

@@ -0,0 +1,157 @@
import { ListingEntity } from '@modules/listings/domain/entities/listing.entity';
import { Price } from '@modules/listings/domain/value-objects/price.vo';
import { CheckQuotaQuery, MeterUsageCommand } from '@modules/subscriptions';
import { PromoteFeaturedListingCommand } from '../commands/promote-featured-listing/promote-featured-listing.command';
import {
FEATURED_LISTINGS_PROMOTED_METRIC,
PromoteFeaturedListingHandler,
} from '../commands/promote-featured-listing/promote-featured-listing.handler';
function createListing(
id = 'listing-1',
sellerId = 'seller-1',
agentId: string | null = null,
status: 'DRAFT' | 'PENDING_REVIEW' | 'ACTIVE' = 'ACTIVE',
): ListingEntity {
const price = Price.create(2_000_000_000n).unwrap();
const listing = ListingEntity.createNew(id, 'prop-1', sellerId, 'SALE', price, 80, agentId ?? undefined);
if (status === 'PENDING_REVIEW' || status === 'ACTIVE') listing.submitForReview();
if (status === 'ACTIVE') listing.approve();
listing.clearDomainEvents();
return listing;
}
describe('PromoteFeaturedListingHandler', () => {
let handler: PromoteFeaturedListingHandler;
let mockListingRepo: { findById: ReturnType<typeof vi.fn> };
let mockPrisma: { listing: { update: ReturnType<typeof vi.fn> } };
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
let mockQueryBus: { execute: ReturnType<typeof vi.fn> };
let mockLogger: { log: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockListingRepo = { findById: vi.fn() };
mockPrisma = { listing: { update: vi.fn().mockResolvedValue(undefined) } };
mockCommandBus = { execute: vi.fn().mockResolvedValue({ usageRecordId: 'u-1' }) };
mockQueryBus = {
execute: vi.fn().mockResolvedValue({
metric: FEATURED_LISTINGS_PROMOTED_METRIC,
limit: 5,
used: 0,
remaining: 5,
allowed: true,
}),
};
mockLogger = { log: vi.fn(), error: vi.fn() };
handler = new PromoteFeaturedListingHandler(
mockListingRepo as any,
mockPrisma as any,
mockCommandBus as any,
mockQueryBus as any,
mockLogger as any,
);
});
it('promotes an active listing when owner has quota', async () => {
mockListingRepo.findById.mockResolvedValue(createListing('listing-1', 'seller-1', null, 'ACTIVE'));
const before = Date.now();
const result = await handler.execute(
new PromoteFeaturedListingCommand('listing-1', 'seller-1', 7),
);
const after = Date.now();
expect(result.listingId).toBe('listing-1');
expect(result.durationDays).toBe(7);
expect(result.quotaRemaining).toBe(4);
const parsed = Date.parse(result.featuredUntil);
expect(parsed).toBeGreaterThanOrEqual(before + 7 * 24 * 60 * 60 * 1000);
expect(parsed).toBeLessThanOrEqual(after + 7 * 24 * 60 * 60 * 1000 + 1000);
expect(mockPrisma.listing.update).toHaveBeenCalledTimes(1);
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
const meterCall = mockCommandBus.execute.mock.calls[0][0];
expect(meterCall).toBeInstanceOf(MeterUsageCommand);
expect(meterCall.metric).toBe(FEATURED_LISTINGS_PROMOTED_METRIC);
expect(meterCall.count).toBe(1);
});
it('allows the assigned agent to promote', async () => {
mockListingRepo.findById.mockResolvedValue(createListing('listing-1', 'seller-1', 'agent-1', 'ACTIVE'));
const result = await handler.execute(
new PromoteFeaturedListingCommand('listing-1', 'agent-1', 3),
);
expect(result.durationDays).toBe(3);
expect(mockPrisma.listing.update).toHaveBeenCalled();
});
it('extends featuredUntil from the existing expiry when still active', async () => {
const listing = createListing('listing-1', 'seller-1', null, 'ACTIVE');
const future = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000);
(listing as unknown as { _featuredUntil: Date })._featuredUntil = future;
mockListingRepo.findById.mockResolvedValue(listing);
const result = await handler.execute(
new PromoteFeaturedListingCommand('listing-1', 'seller-1', 7),
);
const expected = future.getTime() + 7 * 24 * 60 * 60 * 1000;
expect(Math.abs(Date.parse(result.featuredUntil) - expected)).toBeLessThan(1000);
});
it('rejects promote when quota exhausted', async () => {
mockListingRepo.findById.mockResolvedValue(createListing('listing-1', 'seller-1', null, 'ACTIVE'));
mockQueryBus.execute.mockResolvedValue({
metric: FEATURED_LISTINGS_PROMOTED_METRIC,
limit: 5,
used: 5,
remaining: 0,
allowed: false,
});
await expect(
handler.execute(new PromoteFeaturedListingCommand('listing-1', 'seller-1', 7)),
).rejects.toThrow(/Đã dùng hết|nâng cấp/);
expect(mockPrisma.listing.update).not.toHaveBeenCalled();
expect(mockCommandBus.execute).not.toHaveBeenCalled();
});
it('rejects non-owner / non-agent', async () => {
mockListingRepo.findById.mockResolvedValue(createListing('listing-1', 'seller-1', null, 'ACTIVE'));
await expect(
handler.execute(new PromoteFeaturedListingCommand('listing-1', 'stranger', 7)),
).rejects.toThrow(/người bán|môi giới/);
});
it('rejects non-ACTIVE listing', async () => {
mockListingRepo.findById.mockResolvedValue(createListing('listing-1', 'seller-1', null, 'DRAFT'));
await expect(
handler.execute(new PromoteFeaturedListingCommand('listing-1', 'seller-1', 7)),
).rejects.toThrow(/hoạt động/);
});
it('rejects invalid durationDays', async () => {
mockListingRepo.findById.mockResolvedValue(createListing('listing-1', 'seller-1', null, 'ACTIVE'));
await expect(
handler.execute(new PromoteFeaturedListingCommand('listing-1', 'seller-1', 5 as unknown as 3)),
).rejects.toThrow(/Thời lượng/);
});
it('passes CheckQuotaQuery with the featured_listings_promoted metric', async () => {
mockListingRepo.findById.mockResolvedValue(createListing('listing-1', 'seller-1', null, 'ACTIVE'));
await handler.execute(new PromoteFeaturedListingCommand('listing-1', 'seller-1', 7));
const queryArg = mockQueryBus.execute.mock.calls[0][0];
expect(queryArg).toBeInstanceOf(CheckQuotaQuery);
expect(queryArg.metric).toBe(FEATURED_LISTINGS_PROMOTED_METRIC);
expect(queryArg.userId).toBe('seller-1');
});
});

View File

@@ -0,0 +1,94 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { RecordPriceHistoryHandler } from '../event-handlers/record-price-history.handler';
import { ListingPriceChangedEvent } from '../../domain/events/listing-price-changed.event';
describe('RecordPriceHistoryHandler', () => {
let handler: RecordPriceHistoryHandler;
let mockPrisma: { priceHistory: { create: ReturnType<typeof vi.fn> } };
let mockLogger: { debug: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockPrisma = {
priceHistory: { create: vi.fn().mockResolvedValue({ id: 'ph-1' }) },
};
mockLogger = {
debug: vi.fn(),
error: vi.fn(),
};
handler = new RecordPriceHistoryHandler(mockPrisma as any, mockLogger as any);
});
it('should persist a price history record with correct data', async () => {
const event = new ListingPriceChangedEvent(
'listing-1',
5_000_000_000n,
6_000_000_000n,
'manual_update',
);
await handler.handle(event);
expect(mockPrisma.priceHistory.create).toHaveBeenCalledWith({
data: {
listingId: 'listing-1',
oldPrice: 5_000_000_000n,
newPrice: 6_000_000_000n,
source: 'manual_update',
changedAt: event.occurredAt,
},
});
});
it('should persist source as admin_override when provided', async () => {
const event = new ListingPriceChangedEvent(
'listing-2',
3_000_000_000n,
4_500_000_000n,
'admin_override',
);
await handler.handle(event);
expect(mockPrisma.priceHistory.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ source: 'admin_override' }),
}),
);
});
it('should default source to manual_update', async () => {
const event = new ListingPriceChangedEvent('listing-3', 1_000_000n, 2_000_000n);
await handler.handle(event);
expect(mockPrisma.priceHistory.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ source: 'manual_update' }),
}),
);
});
it('should log debug message on success', async () => {
const event = new ListingPriceChangedEvent('listing-1', 100n, 200n);
await handler.handle(event);
expect(mockLogger.debug).toHaveBeenCalledWith(
expect.stringContaining('listing-1'),
'RecordPriceHistoryHandler',
);
});
it('should log error and not throw when persistence fails', async () => {
mockPrisma.priceHistory.create.mockRejectedValue(new Error('DB connection lost'));
const event = new ListingPriceChangedEvent('listing-1', 100n, 200n);
await expect(handler.handle(event)).resolves.toBeUndefined();
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('DB connection lost'),
expect.any(String),
'RecordPriceHistoryHandler',
);
});
});

View File

@@ -0,0 +1,12 @@
export type AdminFeatureAction = 'feature' | 'unfeature';
export class AdminFeatureListingCommand {
constructor(
public readonly listingId: string,
public readonly adminId: string,
public readonly action: AdminFeatureAction,
public readonly durationDays: number | null,
public readonly reason: string,
public readonly ipAddress: string | null,
) {}
}

View File

@@ -0,0 +1,99 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import {
DomainException,
NotFoundException,
ValidationException,
type LoggerService,
type PrismaService,
} from '@modules/shared';
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
import { AdminFeatureListingCommand } from './admin-feature-listing.command';
const ALLOWED_DURATIONS = new Set<number>([3, 7, 14, 30, 60, 90]);
export interface AdminFeatureListingResult {
listingId: string;
featuredUntil: string | null;
action: 'feature' | 'unfeature';
}
@CommandHandler(AdminFeatureListingCommand)
export class AdminFeatureListingHandler
implements ICommandHandler<AdminFeatureListingCommand>
{
constructor(
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async execute(command: AdminFeatureListingCommand): Promise<AdminFeatureListingResult> {
try {
if (!command.reason || command.reason.trim().length < 5) {
throw new ValidationException('Lý do phải tối thiểu 5 ký tự', { reason: command.reason });
}
const listing = await this.listingRepo.findById(command.listingId);
if (!listing) {
throw new NotFoundException('Listing', command.listingId);
}
let featuredUntil: Date | null;
if (command.action === 'feature') {
if (command.durationDays === null || !ALLOWED_DURATIONS.has(command.durationDays)) {
throw new ValidationException('Thời lượng không hợp lệ', {
durationDays: command.durationDays,
allowed: Array.from(ALLOWED_DURATIONS),
});
}
const now = new Date();
const baseDate =
listing.featuredUntil && listing.featuredUntil > now ? listing.featuredUntil : now;
featuredUntil = new Date(baseDate.getTime() + command.durationDays * 24 * 60 * 60 * 1000);
} else {
featuredUntil = null;
}
await this.prisma.$transaction([
this.prisma.listing.update({
where: { id: command.listingId },
data: { featuredUntil },
}),
this.prisma.adminAuditLog.create({
data: {
action: command.action === 'feature' ? 'LISTING_FEATURED' : 'LISTING_UNFEATURED',
actorId: command.adminId,
targetId: command.listingId,
targetType: 'LISTING',
metadata: {
reason: command.reason,
durationDays: command.durationDays,
featuredUntil: featuredUntil?.toISOString() ?? null,
},
ipAddress: command.ipAddress,
},
}),
]);
this.logger.log(
`Admin ${command.action}: listing=${command.listingId}, admin=${command.adminId}, featuredUntil=${featuredUntil?.toISOString() ?? 'null'}`,
'AdminFeatureListingHandler',
);
return {
listingId: command.listingId,
featuredUntil: featuredUntil ? featuredUntil.toISOString() : null,
action: command.action,
};
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to admin-feature listing: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể cập nhật trạng thái nổi bật');
}
}
}

View File

@@ -0,0 +1,11 @@
export type PromoteFeaturedDuration = 3 | 7 | 14 | 30;
export const PROMOTE_FEATURED_DURATION_VALUES: readonly PromoteFeaturedDuration[] = [3, 7, 14, 30];
export class PromoteFeaturedListingCommand {
constructor(
public readonly listingId: string,
public readonly userId: string,
public readonly durationDays: PromoteFeaturedDuration,
) {}
}

View File

@@ -0,0 +1,117 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type CommandBus, type ICommandHandler, type QueryBus } from '@nestjs/cqrs';
import {
DomainException,
ForbiddenException,
NotFoundException,
ValidationException,
type LoggerService,
type PrismaService,
} from '@modules/shared';
import {
CheckQuotaQuery,
MeterUsageCommand,
type QuotaCheckResult,
} from '@modules/subscriptions';
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
import {
type PromoteFeaturedDuration,
PROMOTE_FEATURED_DURATION_VALUES,
PromoteFeaturedListingCommand,
} from './promote-featured-listing.command';
export const FEATURED_LISTINGS_PROMOTED_METRIC = 'featured_listings_promoted';
export interface PromoteFeaturedListingResult {
listingId: string;
featuredUntil: string;
durationDays: PromoteFeaturedDuration;
quotaRemaining: number | null;
}
@CommandHandler(PromoteFeaturedListingCommand)
export class PromoteFeaturedListingHandler
implements ICommandHandler<PromoteFeaturedListingCommand>
{
constructor(
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
private readonly prisma: PrismaService,
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
private readonly logger: LoggerService,
) {}
async execute(command: PromoteFeaturedListingCommand): Promise<PromoteFeaturedListingResult> {
try {
if (!PROMOTE_FEATURED_DURATION_VALUES.includes(command.durationDays)) {
throw new ValidationException('Thời lượng không hợp lệ', {
durationDays: command.durationDays,
allowed: PROMOTE_FEATURED_DURATION_VALUES,
});
}
const listing = await this.listingRepo.findById(command.listingId);
if (!listing) {
throw new NotFoundException('Listing', command.listingId);
}
if (listing.sellerId !== command.userId && listing.agentId !== command.userId) {
throw new ForbiddenException('Chỉ người bán hoặc môi giới mới có thể đẩy tin nổi bật');
}
if (listing.status !== 'ACTIVE') {
throw new ValidationException('Chỉ tin đăng đang hoạt động mới có thể đẩy nổi bật', {
status: listing.status,
});
}
const quota: QuotaCheckResult = await this.queryBus.execute(
new CheckQuotaQuery(command.userId, FEATURED_LISTINGS_PROMOTED_METRIC),
);
if (!quota.allowed) {
throw new ForbiddenException(
`Đã dùng hết lượt đẩy tin nổi bật trong gói (${quota.used}/${quota.limit}). Vui lòng nâng cấp gói để tiếp tục.`,
);
}
const now = new Date();
const baseDate =
listing.featuredUntil && listing.featuredUntil > now ? listing.featuredUntil : now;
const featuredUntil = new Date(
baseDate.getTime() + command.durationDays * 24 * 60 * 60 * 1000,
);
await this.prisma.listing.update({
where: { id: command.listingId },
data: { featuredUntil },
});
await this.commandBus.execute(
new MeterUsageCommand(command.userId, FEATURED_LISTINGS_PROMOTED_METRIC, 1),
);
const newRemaining = quota.remaining === null ? null : Math.max(0, quota.remaining - 1);
this.logger.log(
`Featured listing promoted via entitlement: listing=${command.listingId}, user=${command.userId}, until=${featuredUntil.toISOString()}, days=${command.durationDays}`,
'PromoteFeaturedListingHandler',
);
return {
listingId: command.listingId,
featuredUntil: featuredUntil.toISOString(),
durationDays: command.durationDays,
quotaRemaining: newRemaining,
};
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to promote featured listing: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể đẩy tin nổi bật');
}
}
}

View File

@@ -16,6 +16,7 @@ export class RecordPriceHistoryHandler implements IEventHandler<ListingPriceChan
listingId: event.aggregateId,
oldPrice: event.oldPrice,
newPrice: event.newPrice,
source: event.source,
changedAt: event.occurredAt,
},
});

View File

@@ -6,6 +6,7 @@ export interface PriceHistoryItem {
id: string;
oldPrice: bigint;
newPrice: bigint;
source: string;
changedAt: Date;
}
@@ -21,6 +22,7 @@ export class GetPriceHistoryHandler implements IQueryHandler<GetPriceHistoryQuer
id: true,
oldPrice: true,
newPrice: true,
source: true,
changedAt: true,
},
});

View File

@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import { ListingApprovedEvent } from '../events/listing-approved.event';
import { ListingCreatedEvent } from '../events/listing-created.event';
import { ListingPriceChangedEvent } from '../events/listing-price-changed.event';
import { ListingSoldEvent } from '../events/listing-sold.event';
import { ListingStatusChangedEvent } from '../events/listing-status-changed.event';
@@ -51,6 +52,34 @@ describe('Listings Domain Events', () => {
});
});
describe('ListingPriceChangedEvent', () => {
it('creates event with correct properties', () => {
const event = new ListingPriceChangedEvent('listing-1', 5_000_000_000n, 6_000_000_000n, 'manual_update');
expect(event.eventName).toBe('listing.price_changed');
expect(event.aggregateId).toBe('listing-1');
expect(event.oldPrice).toBe(5_000_000_000n);
expect(event.newPrice).toBe(6_000_000_000n);
expect(event.source).toBe('manual_update');
expect(event.occurredAt).toBeInstanceOf(Date);
});
it('defaults source to manual_update', () => {
const event = new ListingPriceChangedEvent('listing-2', 1_000_000n, 2_000_000n);
expect(event.source).toBe('manual_update');
});
it('accepts admin_override source', () => {
const event = new ListingPriceChangedEvent('listing-3', 1n, 2n, 'admin_override');
expect(event.source).toBe('admin_override');
});
it('accepts market_adjustment source', () => {
const event = new ListingPriceChangedEvent('listing-4', 1n, 2n, 'market_adjustment');
expect(event.source).toBe('market_adjustment');
});
});
describe('ListingStatusChangedEvent', () => {
it('creates event with correct properties', () => {
const event = new ListingStatusChangedEvent('listing-1', 'prop-1', 'DRAFT', 'PENDING_REVIEW');

View File

@@ -142,6 +142,33 @@ describe('ListingEntity', () => {
const fields = listing.updateContent({});
expect(fields).toEqual([]);
});
it('should emit ListingPriceChangedEvent when price actually changes', () => {
const listing = makeDefaultListing();
listing.clearDomainEvents();
listing.updateContent({ priceVND: 6_000_000_000n, areaM2: 100 });
const events = listing.domainEvents;
const priceEvent = events.find((e) => e.eventName === 'listing.price_changed');
expect(priceEvent).toBeDefined();
expect((priceEvent as { oldPrice: bigint; newPrice: bigint }).oldPrice).toBe(
5_000_000_000n,
);
expect((priceEvent as { oldPrice: bigint; newPrice: bigint }).newPrice).toBe(
6_000_000_000n,
);
});
it('should NOT emit ListingPriceChangedEvent when price stays the same', () => {
const listing = makeDefaultListing();
listing.clearDomainEvents();
listing.updateContent({ priceVND: 5_000_000_000n, areaM2: 100 });
const events = listing.domainEvents;
expect(events.some((e) => e.eventName === 'listing.price_changed')).toBe(false);
});
});
describe('markEditedForReModeration', () => {

View File

@@ -1,5 +1,7 @@
import type { DomainEvent } from '@modules/shared';
export type PriceChangeSource = 'manual_update' | 'admin_override' | 'market_adjustment';
export class ListingPriceChangedEvent implements DomainEvent {
readonly eventName = 'listing.price_changed';
readonly occurredAt = new Date();
@@ -8,5 +10,6 @@ export class ListingPriceChangedEvent implements DomainEvent {
public readonly aggregateId: string,
public readonly oldPrice: bigint,
public readonly newPrice: bigint,
public readonly source: PriceChangeSource = 'manual_update',
) {}
}

View File

@@ -2,6 +2,19 @@ export { ListingsModule } from './listings.module';
export { ListingEntity, type ListingProps } from './domain/entities/listing.entity';
export { ListingCreatedEvent } from './domain/events/listing-created.event';
export { ModerateListingCommand } from './application/commands/moderate-listing/moderate-listing.command';
export {
AdminFeatureListingCommand,
type AdminFeatureAction,
} from './application/commands/admin-feature-listing/admin-feature-listing.command';
export { type AdminFeatureListingResult } from './application/commands/admin-feature-listing/admin-feature-listing.handler';
export {
PromoteFeaturedListingCommand,
type PromoteFeaturedDuration,
} from './application/commands/promote-featured-listing/promote-featured-listing.command';
export {
type PromoteFeaturedListingResult,
FEATURED_LISTINGS_PROMOTED_METRIC,
} from './application/commands/promote-featured-listing/promote-featured-listing.handler';
export { LISTING_REPOSITORY, IListingRepository, type ListingSearchParams, type PaginatedResult } from './domain/repositories/listing.repository';
export { ListingPriceChangedEvent } from './domain/events/listing-price-changed.event';
export { ListingSoldEvent } from './domain/events/listing-sold.event';

View File

@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { MulterModule } from '@nestjs/platform-express';
import { AdminFeatureListingHandler } from './application/commands/admin-feature-listing/admin-feature-listing.handler';
import { CreateListingHandler } from './application/commands/create-listing/create-listing.handler';
import { FeatureListingHandler } from './application/commands/feature-listing/feature-listing.handler';
import { ModerateListingHandler } from './application/commands/moderate-listing/moderate-listing.handler';
import { PromoteFeaturedListingHandler } from './application/commands/promote-featured-listing/promote-featured-listing.handler';
import { UpdateListingHandler } from './application/commands/update-listing/update-listing.handler';
import { UpdateListingStatusHandler } from './application/commands/update-listing-status/update-listing-status.handler';
import { UploadMediaHandler } from './application/commands/upload-media/upload-media.handler';
@@ -28,6 +30,8 @@ import { ListingsController } from './presentation/controllers/listings.controll
const CommandHandlers = [
CreateListingHandler,
FeatureListingHandler,
PromoteFeaturedListingHandler,
AdminFeatureListingHandler,
UpdateListingHandler,
UpdateListingStatusHandler,
UploadMediaHandler,

View File

@@ -96,6 +96,78 @@ describe('ListingsController', () => {
});
});
describe('getPriceHistory', () => {
it('should execute GetPriceHistoryQuery via query bus', async () => {
const mockHistory = [
{ id: 'ph-1', oldPrice: '5000000000', newPrice: '6000000000', source: 'manual_update', changedAt: '2026-04-16T00:00:00.000Z' },
];
mockQueryBus.execute.mockResolvedValue(mockHistory);
const result = await controller.getPriceHistory('listing-1');
expect(result).toEqual(mockHistory);
expect(mockQueryBus.execute).toHaveBeenCalledTimes(1);
});
it('should return empty array when no price history exists', async () => {
mockQueryBus.execute.mockResolvedValue([]);
const result = await controller.getPriceHistory('listing-no-history');
expect(result).toEqual([]);
expect(mockQueryBus.execute).toHaveBeenCalledTimes(1);
});
});
describe('updateListing', () => {
it('should execute UpdateListingCommand via command bus', async () => {
const mockResult = {
listingId: 'listing-1',
status: 'DRAFT',
updatedFields: ['title', 'priceVND'],
resubmittedForModeration: false,
};
mockCommandBus.execute.mockResolvedValue(mockResult);
const dto = {
title: 'Căn hộ 3PN view sông mới',
priceVND: 6_000_000_000n,
};
const user = { sub: 'seller-1', email: 'seller@example.com', role: 'SELLER' };
const result = await controller.updateListing('listing-1', dto as any, user as any);
expect(result).toEqual(mockResult);
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should pass all optional fields to the command', async () => {
const mockResult = {
listingId: 'listing-1',
status: 'PENDING_REVIEW',
updatedFields: ['title', 'description', 'priceVND', 'rentPriceMonthly', 'amenities', 'mediaOrder'],
resubmittedForModeration: true,
};
mockCommandBus.execute.mockResolvedValue(mockResult);
const dto = {
title: 'Tiêu đề cập nhật',
description: 'Mô tả chi tiết hơn cho căn hộ',
priceVND: 5_500_000_000n,
rentPriceMonthly: 25_000_000n,
amenities: ['Hồ bơi', 'Gym'],
mediaOrder: [{ mediaId: 'media-1', order: 0 }],
};
const user = { sub: 'seller-1', email: 'seller@example.com', role: 'SELLER' };
const result = await controller.updateListing('listing-1', dto as any, user as any);
expect(result.resubmittedForModeration).toBe(true);
expect(result.status).toBe('PENDING_REVIEW');
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});
describe('updateStatus', () => {
it('should execute UpdateListingStatusCommand via command bus', async () => {
mockCommandBus.execute.mockResolvedValue({ status: 'ACTIVE' });

View File

@@ -33,6 +33,8 @@ import type { CreateListingResult } from '../../application/commands/create-list
import { FeatureListingCommand } from '../../application/commands/feature-listing/feature-listing.command';
import type { FeatureListingResult } from '../../application/commands/feature-listing/feature-listing.handler';
import { ModerateListingCommand } from '../../application/commands/moderate-listing/moderate-listing.command';
import { PromoteFeaturedListingCommand } from '../../application/commands/promote-featured-listing/promote-featured-listing.command';
import type { PromoteFeaturedListingResult } from '../../application/commands/promote-featured-listing/promote-featured-listing.handler';
import { UpdateListingCommand } from '../../application/commands/update-listing/update-listing.command';
import type { UpdateListingResult } from '../../application/commands/update-listing/update-listing.handler';
import { UpdateListingStatusCommand } from '../../application/commands/update-listing-status/update-listing-status.command';
@@ -47,6 +49,7 @@ import type { PaginatedResult } from '../../domain/repositories/listing.reposito
import type { CreateListingDto } from '../dto/create-listing.dto';
import type { FeatureListingDto } from '../dto/feature-listing.dto';
import type { ModerateListingDto } from '../dto/moderate-listing.dto';
import type { PromoteFeaturedListingDto } from '../dto/promote-featured-listing.dto';
import { type SearchListingsDto } from '../dto/search-listings.dto';
import type { UpdateListingStatusDto } from '../dto/update-listing-status.dto';
import type { UpdateListingDto } from '../dto/update-listing.dto';
@@ -319,4 +322,28 @@ export class ListingsController {
new FeatureListingCommand(id, user.sub, dto.package, dto.provider, dto.returnUrl, ip),
);
}
@ApiBearerAuth('JWT')
@ApiOperation({
summary: 'Promote a listing via subscription entitlement (no payment)',
description:
'Sử dụng quota `featured_listings_promoted` của subscription để bật featured không qua thanh toán.',
})
@ApiParam({ name: 'id', description: 'Listing UUID' })
@ApiResponse({ status: 201, description: 'Listing promoted successfully' })
@ApiResponse({ status: 400, description: 'Invalid duration or listing not ACTIVE' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Not owner/agent or quota exhausted' })
@UseGuards(JwtAuthGuard, QuotaGuard)
@RequireQuota('featured_listings_promoted')
@Post(':id/promote')
async promoteListing(
@Param('id') id: string,
@Body() dto: PromoteFeaturedListingDto,
@CurrentUser() user: JwtPayload,
): Promise<PromoteFeaturedListingResult> {
return this.commandBus.execute(
new PromoteFeaturedListingCommand(id, user.sub, dto.durationDays),
);
}
}

View File

@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsIn, IsInt } from 'class-validator';
import { type PromoteFeaturedDuration } from '../../application/commands/promote-featured-listing/promote-featured-listing.command';
const ALLOWED_DURATIONS: readonly number[] = [3, 7, 14, 30];
export class PromoteFeaturedListingDto {
@ApiProperty({
enum: ALLOWED_DURATIONS,
example: 7,
description: 'Số ngày đẩy nổi bật (dùng quota subscription, không phát sinh thanh toán)',
})
@Type(() => Number)
@IsInt()
@IsIn([...ALLOWED_DURATIONS])
durationDays!: PromoteFeaturedDuration;
}

View File

@@ -11,6 +11,7 @@ import { McpTransportController } from './presentation/mcp-transport.controller'
AuthModule,
McpCoreModule.forRoot({
aiServiceBaseUrl: process.env['AI_SERVICE_URL'] || 'http://localhost:8000',
apiBaseUrl: process.env['API_BASE_URL'] || 'http://localhost:3001/api/v1',
typesenseCollectionName: 'listings',
skipDefaultController: true,
}),

View File

@@ -9,6 +9,11 @@ describe('MetricsService', () => {
let mockSearchQueriesCounter: { inc: ReturnType<typeof vi.fn> };
let mockRequestDurationHistogram: { observe: ReturnType<typeof vi.fn> };
let mockHttpRequestsCounter: { inc: ReturnType<typeof vi.fn> };
let mockWsConnectedClientsGauge: {
inc: ReturnType<typeof vi.fn>;
set: ReturnType<typeof vi.fn>;
};
let mockWsMessagesCounter: { inc: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockListingsCreatedCounter = { inc: vi.fn() };
@@ -17,6 +22,8 @@ describe('MetricsService', () => {
mockSearchQueriesCounter = { inc: vi.fn() };
mockRequestDurationHistogram = { observe: vi.fn() };
mockHttpRequestsCounter = { inc: vi.fn() };
mockWsConnectedClientsGauge = { inc: vi.fn(), set: vi.fn() };
mockWsMessagesCounter = { inc: vi.fn() };
service = new MetricsService(
mockListingsCreatedCounter as unknown as Counter,
@@ -25,6 +32,8 @@ describe('MetricsService', () => {
mockSearchQueriesCounter as unknown as Counter,
mockRequestDurationHistogram as unknown as Histogram,
mockHttpRequestsCounter as unknown as Counter,
mockWsConnectedClientsGauge as unknown as Gauge,
mockWsMessagesCounter as unknown as Counter,
);
});
@@ -102,4 +111,41 @@ describe('MetricsService', () => {
expect.objectContaining({ status_code: '503' }),
);
});
it('recordWsConnection increments the connected-clients gauge with +1 on connect', () => {
service.recordWsConnection('/notifications', 1);
expect(mockWsConnectedClientsGauge.inc).toHaveBeenCalledWith(
{ namespace: '/notifications' },
1,
);
});
it('recordWsConnection decrements the connected-clients gauge with -1 on disconnect', () => {
service.recordWsConnection('/notifications', -1);
expect(mockWsConnectedClientsGauge.inc).toHaveBeenCalledWith(
{ namespace: '/notifications' },
-1,
);
});
it('setWsConnectedClients sets the gauge for a namespace', () => {
service.setWsConnectedClients('/notifications', 0);
expect(mockWsConnectedClientsGauge.set).toHaveBeenCalledWith(
{ namespace: '/notifications' },
0,
);
});
it('recordWsMessage increments the messages counter with namespace/event/direction', () => {
service.recordWsMessage('/notifications', 'notification:new', 'out');
expect(mockWsMessagesCounter.inc).toHaveBeenCalledWith({
namespace: '/notifications',
event: 'notification:new',
direction: 'out',
});
});
});

View File

@@ -8,6 +8,8 @@ import {
GOODGO_SEARCH_QUERIES_TOTAL,
GOODGO_API_REQUEST_DURATION,
HTTP_REQUESTS_TOTAL,
GOODGO_WS_CONNECTED_CLIENTS,
GOODGO_WS_MESSAGES_TOTAL,
WEB_VITALS_LCP,
WEB_VITALS_FCP,
WEB_VITALS_CLS,
@@ -31,6 +33,10 @@ export class MetricsService {
private readonly requestDurationHistogram: Histogram,
@InjectMetric(HTTP_REQUESTS_TOTAL)
private readonly httpRequestsCounter: Counter,
@InjectMetric(GOODGO_WS_CONNECTED_CLIENTS)
private readonly wsConnectedClientsGauge: Gauge,
@InjectMetric(GOODGO_WS_MESSAGES_TOTAL)
private readonly wsMessagesCounter: Counter,
@InjectMetric(WEB_VITALS_LCP)
private readonly lcpHistogram: Histogram,
@InjectMetric(WEB_VITALS_FCP)
@@ -81,6 +87,25 @@ export class MetricsService {
this.httpRequestsCounter.inc(labels);
}
/** Track a WebSocket client connection (++) or disconnection (--). */
recordWsConnection(namespace: string, delta: 1 | -1): void {
this.wsConnectedClientsGauge.inc({ namespace }, delta);
}
/** Reset the connected-clients gauge for a namespace (e.g. on shutdown). */
setWsConnectedClients(namespace: string, count: number): void {
this.wsConnectedClientsGauge.set({ namespace }, count);
}
/** Record a WebSocket message emitted/received on a given event. */
recordWsMessage(
namespace: string,
event: string,
direction: 'in' | 'out',
): void {
this.wsMessagesCounter.inc({ namespace, event, direction });
}
/** Map metric name → the correct histogram. */
private readonly vitalHistograms: Record<string, Histogram | undefined> = {};

View File

@@ -11,6 +11,10 @@ export const DB_QUERY_DURATION = 'db_query_duration_seconds';
export const DB_POOL_ACTIVE_CONNECTIONS = 'db_pool_active_connections';
export const SEARCH_QUERY_DURATION = 'search_query_duration_seconds';
// ── WebSocket Metrics ──
export const GOODGO_WS_CONNECTED_CLIENTS = 'goodgo_ws_connected_clients';
export const GOODGO_WS_MESSAGES_TOTAL = 'goodgo_ws_messages_total';
// ── Web Vitals / RUM Metrics ──
export const WEB_VITALS_LCP = 'goodgo_web_vitals_lcp_seconds';
export const WEB_VITALS_FCP = 'goodgo_web_vitals_fcp_seconds';

View File

@@ -15,6 +15,8 @@ import {
DB_QUERY_DURATION,
DB_POOL_ACTIVE_CONNECTIONS,
SEARCH_QUERY_DURATION,
GOODGO_WS_CONNECTED_CLIENTS,
GOODGO_WS_MESSAGES_TOTAL,
WEB_VITALS_LCP,
WEB_VITALS_FCP,
WEB_VITALS_CLS,
@@ -83,6 +85,18 @@ import { HttpMetricsInterceptor } from './presentation/interceptors/http-metrics
labelNames: ['plan'],
}),
// ── WebSocket Metrics ──
makeGaugeProvider({
name: GOODGO_WS_CONNECTED_CLIENTS,
help: 'Number of active WebSocket clients',
labelNames: ['namespace'],
}),
makeCounterProvider({
name: GOODGO_WS_MESSAGES_TOTAL,
help: 'Total number of WebSocket messages emitted/received',
labelNames: ['namespace', 'event', 'direction'],
}),
// ── Services & Interceptors ──
MetricsService,
HttpMetricsInterceptor,

View File

@@ -0,0 +1,223 @@
import { ListingApprovedEvent } from '@modules/admin/domain/events/listing-approved.event';
import { InquiryReadEvent } from '@modules/inquiries/domain/events/inquiry-read.event';
import { ListingPriceChangedEvent } from '@modules/listings/domain/events/listing-price-changed.event';
import {
ResidentialInquiryReplyListener,
ResidentialNewListingInProjectListener,
ResidentialPriceDropListener,
} from '../listeners/residential-events.listener';
function createMockPrisma() {
return {
listing: { findUnique: vi.fn() },
savedSearch: { findMany: vi.fn().mockResolvedValue([]) },
};
}
function createMockGateway() {
return {
emitResidentialEvent: vi.fn(),
};
}
function createMockLogger() {
return { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
}
describe('ResidentialPriceDropListener', () => {
let listener: ResidentialPriceDropListener;
let prisma: ReturnType<typeof createMockPrisma>;
let gateway: ReturnType<typeof createMockGateway>;
let logger: ReturnType<typeof createMockLogger>;
const listing = {
id: 'listing-1',
sellerId: 'seller-1',
transactionType: 'SALE',
priceVND: 2_000_000_000n,
property: {
title: 'Căn hộ 2PN Quận 7',
propertyType: 'APARTMENT',
areaM2: 70,
bedrooms: 2,
district: 'Quận 7',
city: 'Hồ Chí Minh',
projectDevelopmentId: null,
},
};
beforeEach(() => {
prisma = createMockPrisma();
gateway = createMockGateway();
logger = createMockLogger();
listener = new ResidentialPriceDropListener(
prisma as any,
gateway as any,
logger as any,
);
});
it('emits residential:price-drop to each user with a matching saved search', async () => {
prisma.listing.findUnique.mockResolvedValue(listing);
prisma.savedSearch.findMany.mockResolvedValue([
{
id: 'ss-1',
userId: 'user-1',
name: 'Quận 7 căn hộ',
filters: { city: 'Hồ Chí Minh', district: 'Quận 7', priceMax: 3_000_000_000 },
},
{
id: 'ss-2',
userId: 'user-2',
name: 'Quận 1',
filters: { district: 'Quận 1' },
},
]);
const event = new ListingPriceChangedEvent('listing-1', 2_500_000_000n, 2_000_000_000n);
await listener.handle(event);
expect(gateway.emitResidentialEvent).toHaveBeenCalledTimes(1);
expect(gateway.emitResidentialEvent).toHaveBeenCalledWith(
'user-1',
'residential:price-drop',
expect.objectContaining({
listingId: 'listing-1',
savedSearchId: 'ss-1',
oldPrice: '2500000000',
newPrice: '2000000000',
}),
);
});
it('does not emit when the new price is not lower than the old price', async () => {
const event = new ListingPriceChangedEvent('listing-1', 1_000_000_000n, 1_200_000_000n);
await listener.handle(event);
expect(prisma.listing.findUnique).not.toHaveBeenCalled();
expect(gateway.emitResidentialEvent).not.toHaveBeenCalled();
});
it('skips saved searches owned by the listing seller', async () => {
prisma.listing.findUnique.mockResolvedValue(listing);
prisma.savedSearch.findMany.mockResolvedValue([
{ id: 'ss-self', userId: 'seller-1', name: 'mine', filters: {} },
]);
const event = new ListingPriceChangedEvent('listing-1', 2_500_000_000n, 2_000_000_000n);
await listener.handle(event);
expect(gateway.emitResidentialEvent).not.toHaveBeenCalled();
});
it('swallows infrastructure errors without throwing', async () => {
prisma.listing.findUnique.mockRejectedValue(new Error('db down'));
const event = new ListingPriceChangedEvent('listing-1', 2_000_000_000n, 1_000_000_000n);
await expect(listener.handle(event)).resolves.not.toThrow();
expect(logger.warn).toHaveBeenCalled();
});
});
describe('ResidentialNewListingInProjectListener', () => {
let listener: ResidentialNewListingInProjectListener;
let prisma: ReturnType<typeof createMockPrisma>;
let gateway: ReturnType<typeof createMockGateway>;
let logger: ReturnType<typeof createMockLogger>;
beforeEach(() => {
prisma = createMockPrisma();
gateway = createMockGateway();
logger = createMockLogger();
listener = new ResidentialNewListingInProjectListener(
prisma as any,
gateway as any,
logger as any,
);
});
it('emits residential:new-listing-in-project to users tracking the project', async () => {
prisma.listing.findUnique.mockResolvedValue({
id: 'listing-9',
sellerId: 'seller-9',
priceVND: 3_500_000_000n,
property: {
title: 'Vinhomes Grand Park S5.02',
district: 'Quận 9',
city: 'Hồ Chí Minh',
projectDevelopmentId: 'project-vgp',
},
});
prisma.savedSearch.findMany.mockResolvedValue([
{ id: 'ss-tracker', userId: 'user-10', name: 'VGP', filters: { projectId: 'project-vgp' } },
{ id: 'ss-other', userId: 'user-11', name: 'khác', filters: { projectId: 'project-other' } },
{ id: 'ss-no-project', userId: 'user-12', name: 'no-project', filters: {} },
]);
const event = new ListingApprovedEvent('listing-9', 'admin-1');
await listener.handle(event);
expect(gateway.emitResidentialEvent).toHaveBeenCalledTimes(1);
expect(gateway.emitResidentialEvent).toHaveBeenCalledWith(
'user-10',
'residential:new-listing-in-project',
expect.objectContaining({
listingId: 'listing-9',
projectId: 'project-vgp',
price: '3500000000',
}),
);
});
it('does not emit when the listing has no linked project', async () => {
prisma.listing.findUnique.mockResolvedValue({
id: 'listing-9',
sellerId: 'seller-9',
priceVND: 1n,
property: { title: 't', district: 'd', city: 'c', projectDevelopmentId: null },
});
const event = new ListingApprovedEvent('listing-9', 'admin-1');
await listener.handle(event);
expect(prisma.savedSearch.findMany).not.toHaveBeenCalled();
expect(gateway.emitResidentialEvent).not.toHaveBeenCalled();
});
});
describe('ResidentialInquiryReplyListener', () => {
let listener: ResidentialInquiryReplyListener;
let gateway: ReturnType<typeof createMockGateway>;
let logger: ReturnType<typeof createMockLogger>;
beforeEach(() => {
gateway = createMockGateway();
logger = createMockLogger();
listener = new ResidentialInquiryReplyListener(gateway as any, logger as any);
});
it('emits residential:inquiry-reply to the inquiry author', async () => {
const event = new InquiryReadEvent('inq-1', 'listing-1', 'user-author');
await listener.handle(event);
expect(gateway.emitResidentialEvent).toHaveBeenCalledWith(
'user-author',
'residential:inquiry-reply',
expect.objectContaining({
inquiryId: 'inq-1',
listingId: 'listing-1',
}),
);
});
it('swallows emission errors without throwing', async () => {
gateway.emitResidentialEvent.mockImplementation(() => {
throw new Error('server error');
});
const event = new InquiryReadEvent('inq-2', 'listing-2', 'user-2');
await expect(listener.handle(event)).resolves.not.toThrow();
expect(logger.warn).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type PhoneChangeRequestedEvent } from '@modules/auth';
import { type LoggerService } from '@modules/shared';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
@Injectable()
export class PhoneChangeRequestedListener {
constructor(
private readonly commandBus: CommandBus,
private readonly logger: LoggerService,
) {}
@OnEvent('user.phone_change_requested', { async: true })
async handle(event: PhoneChangeRequestedEvent): Promise<void> {
this.logger.log(
`Handling phone change OTP for user ${event.aggregateId}`,
'PhoneChangeRequestedListener',
);
await this.commandBus.execute(
new SendNotificationCommand(
event.aggregateId,
'SMS',
'user.phone_change_otp',
{ otpCode: event.otpCode },
event.newPhone,
),
);
}
}

View File

@@ -0,0 +1,242 @@
import { EventsHandler, type IEventHandler } from '@nestjs/cqrs';
import { ListingApprovedEvent } from '@modules/admin';
import { InquiryReadEvent } from '@modules/inquiries';
import { ListingPriceChangedEvent } from '@modules/listings';
import { type LoggerService, type PrismaService } from '@modules/shared';
import { type NotificationsGateway } from '../../presentation/gateways/notifications.gateway';
const CONTEXT = 'ResidentialEventsListener';
/**
* Shape of the `filters` JSON column on `SavedSearch`. Matches fields
* consumed by the saved-search alert matcher. Anything else is ignored.
*/
interface SavedSearchFilters {
transactionType?: string;
propertyType?: string;
projectId?: string;
district?: string;
city?: string;
priceMin?: number;
priceMax?: number;
areaMin?: number;
areaMax?: number;
bedrooms?: number;
}
/**
* Fans residential domain events out as Socket.IO events on the
* `/notifications` namespace so subscribed users get live updates
* without waiting for the email/push pipeline.
*
* Three WS events are emitted:
* • `residential:price-drop` — listing price lowered and matches an
* alert-enabled saved search.
* • `residential:new-listing-in-project` — approved listing lives in
* a project that the user tracks via `filters.projectId`.
* • `residential:inquiry-reply` — the listing owner/agent marked the
* user's inquiry as read, signalling that a reply is incoming.
*
* Redis pub/sub fan-out is handled by {@link RedisIoAdapter}, so the
* broadcast reaches the user's socket regardless of which API pod
* holds the connection.
*/
@EventsHandler(ListingPriceChangedEvent)
export class ResidentialPriceDropListener
implements IEventHandler<ListingPriceChangedEvent>
{
constructor(
private readonly prisma: PrismaService,
private readonly gateway: NotificationsGateway,
private readonly logger: LoggerService,
) {}
async handle(event: ListingPriceChangedEvent): Promise<void> {
if (event.newPrice >= event.oldPrice) {
return;
}
try {
const listing = await this.prisma.listing.findUnique({
where: { id: event.aggregateId },
include: { property: true },
});
if (!listing || !listing.property) return;
const savedSearches = await this.prisma.savedSearch.findMany({
where: { alertEnabled: true },
select: { id: true, userId: true, name: true, filters: true },
});
let matchCount = 0;
for (const search of savedSearches) {
if (search.userId === listing.sellerId) continue;
const filters = normalizeFilters(search.filters);
if (!matchesFilters(listing, listing.property, filters)) continue;
this.gateway.emitResidentialEvent(search.userId, 'residential:price-drop', {
listingId: listing.id,
savedSearchId: search.id,
savedSearchName: search.name,
title: listing.property.title,
oldPrice: event.oldPrice.toString(),
newPrice: event.newPrice.toString(),
district: listing.property.district,
city: listing.property.city,
occurredAt: event.occurredAt.toISOString(),
});
matchCount++;
}
if (matchCount > 0) {
this.logger.log(
`Emitted residential:price-drop to ${matchCount} users for listing ${listing.id}`,
CONTEXT,
);
}
} catch (err) {
this.logger.warn(
`Price-drop WS emission failed for listing ${event.aggregateId}: ${
err instanceof Error ? err.message : String(err)
}`,
CONTEXT,
);
}
}
}
@EventsHandler(ListingApprovedEvent)
export class ResidentialNewListingInProjectListener
implements IEventHandler<ListingApprovedEvent>
{
constructor(
private readonly prisma: PrismaService,
private readonly gateway: NotificationsGateway,
private readonly logger: LoggerService,
) {}
async handle(event: ListingApprovedEvent): Promise<void> {
try {
const listing = await this.prisma.listing.findUnique({
where: { id: event.aggregateId },
include: { property: true },
});
if (!listing || !listing.property?.projectDevelopmentId) return;
const projectId = listing.property.projectDevelopmentId;
const savedSearches = await this.prisma.savedSearch.findMany({
where: { alertEnabled: true },
select: { id: true, userId: true, name: true, filters: true },
});
let matchCount = 0;
for (const search of savedSearches) {
if (search.userId === listing.sellerId) continue;
const filters = normalizeFilters(search.filters);
if (filters.projectId !== projectId) continue;
this.gateway.emitResidentialEvent(
search.userId,
'residential:new-listing-in-project',
{
listingId: listing.id,
projectId,
savedSearchId: search.id,
savedSearchName: search.name,
title: listing.property.title,
price: listing.priceVND.toString(),
district: listing.property.district,
city: listing.property.city,
occurredAt: event.occurredAt.toISOString(),
},
);
matchCount++;
}
if (matchCount > 0) {
this.logger.log(
`Emitted residential:new-listing-in-project to ${matchCount} users for project ${projectId}`,
CONTEXT,
);
}
} catch (err) {
this.logger.warn(
`New-listing-in-project WS emission failed for listing ${event.aggregateId}: ${
err instanceof Error ? err.message : String(err)
}`,
CONTEXT,
);
}
}
}
@EventsHandler(InquiryReadEvent)
export class ResidentialInquiryReplyListener
implements IEventHandler<InquiryReadEvent>
{
constructor(
private readonly gateway: NotificationsGateway,
private readonly logger: LoggerService,
) {}
async handle(event: InquiryReadEvent): Promise<void> {
try {
this.gateway.emitResidentialEvent(event.userId, 'residential:inquiry-reply', {
inquiryId: event.aggregateId,
listingId: event.listingId,
occurredAt: event.occurredAt.toISOString(),
});
} catch (err) {
this.logger.warn(
`Inquiry-reply WS emission failed for inquiry ${event.aggregateId}: ${
err instanceof Error ? err.message : String(err)
}`,
CONTEXT,
);
}
}
}
/* ────────────────────────────────────────────
* Private helpers
* ──────────────────────────────────────────── */
function normalizeFilters(raw: unknown): SavedSearchFilters {
if (!raw || typeof raw !== 'object') return {};
return raw as SavedSearchFilters;
}
function matchesFilters(
listing: { transactionType: string; priceVND: bigint; sellerId: string },
property: {
propertyType: string;
areaM2: number;
bedrooms: number | null;
district: string;
city: string;
},
filters: SavedSearchFilters,
): boolean {
if (filters.transactionType && filters.transactionType !== listing.transactionType) return false;
if (filters.propertyType && filters.propertyType !== property.propertyType) return false;
if (filters.district && filters.district !== property.district) return false;
if (filters.city && filters.city !== property.city) return false;
const price = Number(listing.priceVND);
if (filters.priceMin !== undefined && price < Number(filters.priceMin)) return false;
if (filters.priceMax !== undefined && price > Number(filters.priceMax)) return false;
if (filters.areaMin !== undefined && property.areaM2 < Number(filters.areaMin)) return false;
if (filters.areaMax !== undefined && property.areaM2 > Number(filters.areaMax)) return false;
if (
filters.bedrooms !== undefined &&
property.bedrooms !== null &&
property.bedrooms < Number(filters.bedrooms)
) {
return false;
}
return true;
}

View File

@@ -14,3 +14,9 @@ export {
NotificationChannel,
ALL_CHANNELS,
} from './value-objects/notification-channel.vo';
export {
SMS_NOTIFICATION_CHANNEL,
type NotificationChannelPort,
type SendChannelMessageDto,
type SendChannelMessageResult,
} from './ports/notification-channel.port';

View File

@@ -0,0 +1,21 @@
import { type NotificationChannel } from '../value-objects/notification-channel.vo';
export interface SendChannelMessageDto {
recipient: string;
subject: string;
body: string;
templateKey: string;
metadata?: Record<string, unknown>;
}
export interface SendChannelMessageResult {
messageId: string;
}
export interface NotificationChannelPort {
readonly channel: NotificationChannel;
readonly isAvailable: boolean;
send(dto: SendChannelMessageDto): Promise<SendChannelMessageResult>;
}
export const SMS_NOTIFICATION_CHANNEL = Symbol('SMS_NOTIFICATION_CHANNEL');

View File

@@ -0,0 +1,77 @@
import {
SMS_RATE_LIMIT_BUCKETS,
SmsRateLimiterService,
} from '../services/sms-rate-limiter.service';
describe('SmsRateLimiterService', () => {
let mockRedis: { getClient: ReturnType<typeof vi.fn> };
let mockClient: { eval: ReturnType<typeof vi.fn> };
let mockLogger: {
log: ReturnType<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
};
let service: SmsRateLimiterService;
beforeEach(() => {
mockClient = { eval: vi.fn() };
mockRedis = { getClient: vi.fn().mockReturnValue(mockClient) };
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
service = new SmsRateLimiterService(mockRedis as any, mockLogger as any);
});
it('allows the request when Lua script reports under limit', async () => {
mockClient.eval.mockResolvedValue([1, 0]);
const decision = await service.check('+84901234567', 'otp');
expect(decision.allowed).toBe(true);
expect(decision.current).toBe(1);
expect(decision.limit).toBe(SMS_RATE_LIMIT_BUCKETS.otp.limit);
expect(decision.retryAfterSeconds).toBe(0);
expect(decision.bucket).toBe('otp');
});
it('blocks the request and returns retryAfter when limit reached', async () => {
mockClient.eval.mockResolvedValue([SMS_RATE_LIMIT_BUCKETS.otp.limit, 12_345]);
const decision = await service.check('+84901234567', 'otp');
expect(decision.allowed).toBe(false);
expect(decision.retryAfterSeconds).toBeGreaterThanOrEqual(1);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('SMS rate limit hit'),
'SmsRateLimiterService',
);
});
it('namespaces the key per phone and bucket', async () => {
mockClient.eval.mockResolvedValue([1, 0]);
await service.check('+84901234567', 'transactional');
expect(mockClient.eval).toHaveBeenCalledWith(
expect.any(String),
1,
'sms_rate_limit:transactional:+84901234567',
expect.any(Number),
SMS_RATE_LIMIT_BUCKETS.transactional.windowSeconds * 1000,
SMS_RATE_LIMIT_BUCKETS.transactional.limit,
expect.any(String),
SMS_RATE_LIMIT_BUCKETS.transactional.windowSeconds,
);
});
it('fails open when Redis throws (allows the send, logs warning)', async () => {
mockClient.eval.mockRejectedValue(new Error('redis down'));
const decision = await service.check('+84901234567', 'otpHourly');
expect(decision.allowed).toBe(true);
expect(decision.current).toBe(0);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Redis error'),
'SmsRateLimiterService',
);
});
});

Some files were not shown because too many files have changed in this diff Show More