Compare commits
33 Commits
25f415f3bc
...
a6d1ef307c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6d1ef307c | ||
|
|
38b9def99a | ||
|
|
0f3b4d7b0d | ||
|
|
caa0a58afd | ||
|
|
8c6e3b92d0 | ||
|
|
729afe2db6 | ||
|
|
5731577fa9 | ||
|
|
580eb2a261 | ||
|
|
2c1e3771e9 | ||
|
|
329a821b4a | ||
|
|
5d4ecdeb2f | ||
|
|
e18390ead9 | ||
|
|
78e46a024b | ||
|
|
b21f197c09 | ||
|
|
8e9d021465 | ||
|
|
0dda2bffdb | ||
|
|
9eaec46a37 | ||
|
|
6cf2c23170 | ||
|
|
f3a2a012c4 | ||
|
|
a6e53e3d06 | ||
|
|
74804757c5 | ||
|
|
ac4191cdf0 | ||
|
|
8f2d325d60 | ||
|
|
13bd76ac5d | ||
|
|
8592fb436c | ||
|
|
24a2fd1369 | ||
|
|
a7bcc807ad | ||
|
|
ca41f7e604 | ||
|
|
b22543d59e | ||
|
|
57db3fe388 | ||
|
|
5810f0be56 | ||
|
|
28cdd92846 | ||
|
|
44533a88f4 |
@@ -37,6 +37,7 @@
|
|||||||
"@prisma/client": "^7.7.0",
|
"@prisma/client": "^7.7.0",
|
||||||
"@sentry/nestjs": "^10.47.0",
|
"@sentry/nestjs": "^10.47.0",
|
||||||
"@sentry/profiling-node": "^10.47.0",
|
"@sentry/profiling-node": "^10.47.0",
|
||||||
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"@willsoto/nestjs-prometheus": "^6.1.0",
|
"@willsoto/nestjs-prometheus": "^6.1.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bullmq": "^5.74.1",
|
"bullmq": "^5.74.1",
|
||||||
|
|||||||
@@ -8,11 +8,10 @@ import './instrument';
|
|||||||
|
|
||||||
import { RequestMethod, ValidationPipe } from '@nestjs/common';
|
import { RequestMethod, ValidationPipe } from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { IoAdapter } from '@nestjs/platform-socket.io';
|
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import { LoggerService, validateEnv } from '@modules/shared';
|
import { LoggerService, RedisIoAdapter, validateEnv } from '@modules/shared';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
@@ -60,7 +59,11 @@ async function bootstrap() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── WebSocket Adapter (Socket.IO) ──
|
// ── 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) ──
|
// ── Security Headers (Helmet) ──
|
||||||
app.use(
|
app.use(
|
||||||
|
|||||||
@@ -2,13 +2,19 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
Ip,
|
||||||
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
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 { 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 { ApproveKycCommand } from '../../application/commands/approve-kyc/approve-kyc.command';
|
||||||
import { type ApproveKycResult } from '../../application/commands/approve-kyc/approve-kyc.handler';
|
import { type ApproveKycResult } from '../../application/commands/approve-kyc/approve-kyc.handler';
|
||||||
import { ApproveListingCommand } from '../../application/commands/approve-listing/approve-listing.command';
|
import { ApproveListingCommand } from '../../application/commands/approve-listing/approve-listing.command';
|
||||||
@@ -25,6 +31,7 @@ import {
|
|||||||
type ModerationQueueResult,
|
type ModerationQueueResult,
|
||||||
type KycQueueResult,
|
type KycQueueResult,
|
||||||
} from '../../domain/repositories/admin-query.repository';
|
} 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 ApproveKycDto } from '../dto/approve-kyc.dto';
|
||||||
import { type ApproveListingDto } from '../dto/approve-listing.dto';
|
import { type ApproveListingDto } from '../dto/approve-listing.dto';
|
||||||
import { type BulkModerateDto } from '../dto/bulk-moderate.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 ──
|
// ── KYC ──
|
||||||
|
|
||||||
@Get('kyc')
|
@Get('kyc')
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 { UpdateMarketIndexHandler } from './application/commands/update-market-index/update-market-index.handler';
|
||||||
import { ListingCreatedModerationHandler } from './application/event-handlers/listing-created-moderation.handler';
|
import { ListingCreatedModerationHandler } from './application/event-handlers/listing-created-moderation.handler';
|
||||||
import { BatchValuationHandler } from './application/queries/batch-valuation/batch-valuation.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 { GetDistrictStatsHandler } from './application/queries/get-district-stats/get-district-stats.handler';
|
||||||
import { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap.handler';
|
import { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap.handler';
|
||||||
import { GetMarketReportHandler } from './application/queries/get-market-report/get-market-report.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 { AI_SERVICE_CLIENT, AiServiceClient } from './infrastructure/services/ai-service.client';
|
||||||
import { HttpAVMService } from './infrastructure/services/http-avm.service';
|
import { HttpAVMService } from './infrastructure/services/http-avm.service';
|
||||||
import { MarketIndexCronService } from './infrastructure/services/market-index-cron.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 { PrismaAVMService } from './infrastructure/services/prisma-avm.service';
|
||||||
import { AnalyticsController } from './presentation/controllers/analytics.controller';
|
import { AnalyticsController } from './presentation/controllers/analytics.controller';
|
||||||
|
import { AvmController } from './presentation/controllers/avm.controller';
|
||||||
|
|
||||||
const CommandHandlers = [
|
const CommandHandlers = [
|
||||||
TrackEventHandler,
|
TrackEventHandler,
|
||||||
@@ -42,6 +47,7 @@ const QueryHandlers = [
|
|||||||
ValuationHistoryHandler,
|
ValuationHistoryHandler,
|
||||||
ValuationComparisonHandler,
|
ValuationComparisonHandler,
|
||||||
GetNeighborhoodScoreHandler,
|
GetNeighborhoodScoreHandler,
|
||||||
|
IndustrialValuationHandler,
|
||||||
];
|
];
|
||||||
|
|
||||||
const EventHandlers = [
|
const EventHandlers = [
|
||||||
@@ -50,7 +56,7 @@ const EventHandlers = [
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CqrsModule],
|
imports: [CqrsModule],
|
||||||
controllers: [AnalyticsController],
|
controllers: [AnalyticsController, AvmController],
|
||||||
providers: [
|
providers: [
|
||||||
// AI service client
|
// AI service client
|
||||||
{ provide: AI_SERVICE_CLIENT, useClass: AiServiceClient },
|
{ provide: AI_SERVICE_CLIENT, useClass: AiServiceClient },
|
||||||
@@ -63,8 +69,9 @@ const EventHandlers = [
|
|||||||
PrismaAVMService,
|
PrismaAVMService,
|
||||||
{ provide: AVM_SERVICE, useClass: HttpAVMService },
|
{ provide: AVM_SERVICE, useClass: HttpAVMService },
|
||||||
|
|
||||||
// Neighborhood scoring
|
// Neighborhood scoring: HTTP proxy → Python AI service, falls back to Prisma scoring
|
||||||
{ provide: NEIGHBORHOOD_SCORE_SERVICE, useClass: NeighborhoodScoreServiceImpl },
|
PrismaNeighborhoodScoreService,
|
||||||
|
{ provide: NEIGHBORHOOD_SCORE_SERVICE, useClass: HttpNeighborhoodScoreService },
|
||||||
|
|
||||||
// Cron
|
// Cron
|
||||||
MarketIndexCronService,
|
MarketIndexCronService,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,7 +26,7 @@ export class BatchValuationHandler implements IQueryHandler<BatchValuationQuery>
|
|||||||
...query.propertyIds.slice().sort(),
|
...query.propertyIds.slice().sort(),
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.cache.getOrSet(
|
return await this.cache.getOrSet(
|
||||||
cacheKey,
|
cacheKey,
|
||||||
async () => {
|
async () => {
|
||||||
const items = query.propertyIds.map((propertyId) => ({ propertyId }));
|
const items = query.propertyIds.map((propertyId) => ({ propertyId }));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ export class ValuationComparisonHandler implements IQueryHandler<ValuationCompar
|
|||||||
...query.propertyIds.slice().sort(),
|
...query.propertyIds.slice().sort(),
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.cache.getOrSet(
|
return await this.cache.getOrSet(
|
||||||
cacheKey,
|
cacheKey,
|
||||||
() => this.buildComparison(query.propertyIds),
|
() => this.buildComparison(query.propertyIds),
|
||||||
CacheTTL.MARKET_DATA,
|
CacheTTL.MARKET_DATA,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export class ValuationHistoryHandler implements IQueryHandler<ValuationHistoryQu
|
|||||||
query.limit.toString(),
|
query.limit.toString(),
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.cache.getOrSet(
|
return await this.cache.getOrSet(
|
||||||
cacheKey,
|
cacheKey,
|
||||||
async () => {
|
async () => {
|
||||||
const entities = await this.valuationRepo.findByPropertyId(query.propertyId);
|
const entities = await this.valuationRepo.findByPropertyId(query.propertyId);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,6 +23,55 @@ export interface AiPredictResponse {
|
|||||||
price_range_high: number;
|
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 {
|
export interface AiModerationRequest {
|
||||||
text: string;
|
text: string;
|
||||||
context?: string;
|
context?: string;
|
||||||
@@ -42,11 +91,42 @@ export interface AiModerationResponse {
|
|||||||
cleaned_text: string | null;
|
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 const AI_SERVICE_CLIENT = Symbol('AI_SERVICE_CLIENT');
|
||||||
|
|
||||||
export interface IAiServiceClient {
|
export interface IAiServiceClient {
|
||||||
predict(req: AiPredictRequest): Promise<AiPredictResponse>;
|
predict(req: AiPredictRequest): Promise<AiPredictResponse>;
|
||||||
|
predictIndustrial(req: AiIndustrialPredictRequest): Promise<AiIndustrialPredictResponse>;
|
||||||
moderate(req: AiModerationRequest): Promise<AiModerationResponse>;
|
moderate(req: AiModerationRequest): Promise<AiModerationResponse>;
|
||||||
|
scoreNeighborhood(req: AiNeighborhoodScoreRequest): Promise<AiNeighborhoodScoreResponse>;
|
||||||
isAvailable(): Promise<boolean>;
|
isAvailable(): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,10 +146,20 @@ export class AiServiceClient implements IAiServiceClient {
|
|||||||
return this.post<AiPredictResponse>('/avm/predict', req);
|
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> {
|
async moderate(req: AiModerationRequest): Promise<AiModerationResponse> {
|
||||||
return this.post<AiModerationResponse>('/moderation/check', req);
|
return this.post<AiModerationResponse>('/moderation/check', req);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async scoreNeighborhood(
|
||||||
|
req: AiNeighborhoodScoreRequest,
|
||||||
|
): Promise<AiNeighborhoodScoreResponse> {
|
||||||
|
return this.post<AiNeighborhoodScoreResponse>('/neighborhood/score', req);
|
||||||
|
}
|
||||||
|
|
||||||
async isAvailable(): Promise<boolean> {
|
async isAvailable(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.baseUrl}/health`, {
|
const response = await fetch(`${this.baseUrl}/health`, {
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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 PrismaService, type LoggerService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
type INeighborhoodScoreService,
|
type INeighborhoodScoreService,
|
||||||
type NeighborhoodScoreResult,
|
type NeighborhoodScoreResult,
|
||||||
} from '../../domain/services/neighborhood-score.service';
|
} from '../../domain/services/neighborhood-score.service';
|
||||||
|
import {
|
||||||
|
AI_SERVICE_CLIENT,
|
||||||
|
type AiNeighborhoodPOICounts,
|
||||||
|
type IAiServiceClient,
|
||||||
|
} from './ai-service.client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scoring weights for each POI category.
|
* Scoring weights for each POI category.
|
||||||
* Sum = 100 (total score is 0–100 weighted average).
|
* Sum = 100 (total score is 0–100 weighted average).
|
||||||
|
* Mirrors the Python heuristic in libs/ai-services/app/services/neighborhood_service.py.
|
||||||
*/
|
*/
|
||||||
const CATEGORY_WEIGHTS = {
|
const CATEGORY_WEIGHTS = {
|
||||||
education: 20,
|
education: 20,
|
||||||
@@ -16,20 +23,20 @@ const CATEGORY_WEIGHTS = {
|
|||||||
shopping: 15,
|
shopping: 15,
|
||||||
greenery: 15,
|
greenery: 15,
|
||||||
safety: 10,
|
safety: 10,
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
/** POI types grouped by scoring category. */
|
/** POI types grouped by scoring category. */
|
||||||
const CATEGORY_POI_TYPES: Record<string, string[]> = {
|
const CATEGORY_POI_TYPES: Record<keyof typeof CATEGORY_WEIGHTS, POIType[]> = {
|
||||||
education: ['SCHOOL', 'UNIVERSITY'],
|
education: [POIType.SCHOOL, POIType.UNIVERSITY],
|
||||||
healthcare: ['HOSPITAL', 'CLINIC'],
|
healthcare: [POIType.HOSPITAL, POIType.CLINIC],
|
||||||
transport: ['METRO_STATION', 'BUS_STOP'],
|
transport: [POIType.METRO_STATION, POIType.BUS_STOP],
|
||||||
shopping: ['MALL', 'MARKET', 'SUPERMARKET'],
|
shopping: [POIType.MALL, POIType.MARKET, POIType.SUPERMARKET],
|
||||||
greenery: ['PARK'],
|
greenery: [POIType.PARK],
|
||||||
safety: ['POLICE_STATION', 'FIRE_STATION'],
|
safety: [POIType.POLICE_STATION, POIType.FIRE_STATION],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Max count per category that yields a 10/10 score. */
|
/** 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,
|
education: 15,
|
||||||
healthcare: 8,
|
healthcare: 8,
|
||||||
transport: 12,
|
transport: 12,
|
||||||
@@ -38,8 +45,11 @@ const MAX_COUNTS: Record<string, number> = {
|
|||||||
safety: 4,
|
safety: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CategoryKey = keyof typeof CATEGORY_WEIGHTS;
|
||||||
|
const CATEGORY_KEYS = Object.keys(CATEGORY_WEIGHTS) as CategoryKey[];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NeighborhoodScoreServiceImpl implements INeighborhoodScoreService {
|
export class PrismaNeighborhoodScoreService implements INeighborhoodScoreService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly logger: LoggerService,
|
private readonly logger: LoggerService,
|
||||||
@@ -52,91 +62,179 @@ export class NeighborhoodScoreServiceImpl implements INeighborhoodScoreService {
|
|||||||
|
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
||||||
return {
|
return mapRecord(existing);
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async calculateAndSave(district: string, city: string): Promise<NeighborhoodScoreResult> {
|
async calculateAndSave(district: string, city: string): Promise<NeighborhoodScoreResult> {
|
||||||
// Count POIs per category for this district
|
const counts = await countPOIs(this.prisma, district, city);
|
||||||
const poiCounts: Record<string, number> = {};
|
const subScores = scoreFromCounts(counts);
|
||||||
const categoryScores: Record<string, number> = {};
|
const totalScore = weightedTotal(subScores);
|
||||||
|
|
||||||
for (const [category, poiTypes] of Object.entries(CATEGORY_POI_TYPES)) {
|
const result = await upsertScore(this.prisma, district, city, subScores, totalScore, counts);
|
||||||
const count = await this.prisma.pOI.count({
|
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: {
|
where: {
|
||||||
district,
|
district,
|
||||||
city,
|
city,
|
||||||
type: { in: poiTypes as any },
|
type: { in: CATEGORY_POI_TYPES[cat] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return [cat, count] as const;
|
||||||
poiCounts[category] = count;
|
}),
|
||||||
// Score 0–10: linear scale capped at MAX_COUNTS
|
|
||||||
const maxCount = MAX_COUNTS[category]!;
|
|
||||||
categoryScores[category] = Math.min(10, (count / maxCount) * 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Weighted total score (0–100)
|
|
||||||
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 {
|
return Object.fromEntries(entries) as unknown as AiNeighborhoodPOICounts;
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { type QueryBus } from '@nestjs/cqrs';
|
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 { GetDistrictStatsQuery } from '../../application/queries/get-district-stats/get-district-stats.query';
|
||||||
import { GetHeatmapQuery } from '../../application/queries/get-heatmap/get-heatmap.query';
|
import { GetHeatmapQuery } from '../../application/queries/get-heatmap/get-heatmap.query';
|
||||||
import { GetMarketReportQuery } from '../../application/queries/get-market-report/get-market-report.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 { 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';
|
import { AnalyticsController } from '../controllers/analytics.controller';
|
||||||
|
|
||||||
describe('AnalyticsController', () => {
|
describe('AnalyticsController', () => {
|
||||||
@@ -76,4 +80,80 @@ describe('AnalyticsController', () => {
|
|||||||
);
|
);
|
||||||
expect(result).toBe(expected);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
export { AnalyticsController } from './analytics.controller';
|
export { AnalyticsController } from './analytics.controller';
|
||||||
|
export { AvmController } from './avm.controller';
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -6,3 +6,5 @@ export { GetValuationDto } from './get-valuation.dto';
|
|||||||
export { BatchValuationDto } from './batch-valuation.dto';
|
export { BatchValuationDto } from './batch-valuation.dto';
|
||||||
export { ValuationHistoryDto } from './valuation-history.dto';
|
export { ValuationHistoryDto } from './valuation-history.dto';
|
||||||
export { ValuationComparisonDto } from './valuation-comparison.dto';
|
export { ValuationComparisonDto } from './valuation-comparison.dto';
|
||||||
|
export { AvmCompareQueryDto } from './avm-compare-query.dto';
|
||||||
|
export { IndustrialValuationDto } from './industrial-valuation.dto';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -191,4 +191,85 @@ describe('UpdateProfileHandler', () => {
|
|||||||
expect(mockUserRepo.update).toHaveBeenCalledWith(user);
|
expect(mockUserRepo.update).toHaveBeenCalledWith(user);
|
||||||
expect(mockCache.invalidate).toHaveBeenCalled();
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,5 +4,6 @@ export class UpdateProfileCommand {
|
|||||||
public readonly fullName?: string,
|
public readonly fullName?: string,
|
||||||
public readonly avatarUrl?: string,
|
public readonly avatarUrl?: string,
|
||||||
public readonly email?: string,
|
public readonly email?: string,
|
||||||
|
public readonly phoneNumber?: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import {
|
|||||||
ValidationException,
|
ValidationException,
|
||||||
} from '@modules/shared';
|
} from '@modules/shared';
|
||||||
import { EmailChangeRequestedEvent } from '../../../domain/events/email-change-requested.event';
|
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 { type IUserRepository, USER_REPOSITORY } from '../../../domain/repositories/user.repository';
|
||||||
import { Email } from '../../../domain/value-objects/email.vo';
|
import { Email } from '../../../domain/value-objects/email.vo';
|
||||||
|
import { Phone } from '../../../domain/value-objects/phone.vo';
|
||||||
import { UpdateProfileCommand } from './update-profile.command';
|
import { UpdateProfileCommand } from './update-profile.command';
|
||||||
|
|
||||||
/** TTL for email-change OTP codes stored in Redis (10 minutes). */
|
/** 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. */
|
/** Redis key prefix for pending email-change OTP. */
|
||||||
export const EMAIL_CHANGE_OTP_PREFIX = 'auth: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 {
|
export interface UpdateProfileResultDto {
|
||||||
id: string;
|
id: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
phoneNumber: string;
|
||||||
emailChangePending?: boolean;
|
emailChangePending?: boolean;
|
||||||
|
phoneChangePending?: boolean;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +59,7 @@ export class UpdateProfileHandler implements ICommandHandler<UpdateProfileComman
|
|||||||
}
|
}
|
||||||
|
|
||||||
let emailChangePending = false;
|
let emailChangePending = false;
|
||||||
|
let phoneChangePending = false;
|
||||||
|
|
||||||
// Validate and handle email change via OTP
|
// Validate and handle email change via OTP
|
||||||
if (command.email !== undefined) {
|
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);
|
user.updateProfile(command.fullName, command.avatarUrl, undefined);
|
||||||
await this.userRepo.update(user);
|
await this.userRepo.update(user);
|
||||||
|
|
||||||
@@ -97,7 +142,9 @@ export class UpdateProfileHandler implements ICommandHandler<UpdateProfileComman
|
|||||||
fullName: user.fullName,
|
fullName: user.fullName,
|
||||||
avatarUrl: user.avatarUrl,
|
avatarUrl: user.avatarUrl,
|
||||||
email: user.email?.value ?? null,
|
email: user.email?.value ?? null,
|
||||||
|
phoneNumber: user.phone.value,
|
||||||
...(emailChangePending ? { emailChangePending: true } : {}),
|
...(emailChangePending ? { emailChangePending: true } : {}),
|
||||||
|
...(phoneChangePending ? { phoneChangePending: true } : {}),
|
||||||
updatedAt: user.updatedAt,
|
updatedAt: user.updatedAt,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export class VerifyPhoneChangeCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly code: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import { VerifyEmailChangeHandler } from './application/commands/verify-email-ch
|
|||||||
import { VerifyKycHandler } from './application/commands/verify-kyc/verify-kyc.handler';
|
import { VerifyKycHandler } from './application/commands/verify-kyc/verify-kyc.handler';
|
||||||
import { VerifyMfaChallengeHandler } from './application/commands/verify-mfa-challenge/verify-mfa-challenge.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 { 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 { 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 { GetMfaStatusHandler } from './application/queries/get-mfa-status/get-mfa-status.handler';
|
||||||
import { GetProfileHandler } from './application/queries/get-profile/get-profile.handler';
|
import { GetProfileHandler } from './application/queries/get-profile/get-profile.handler';
|
||||||
@@ -55,6 +56,7 @@ const CommandHandlers = [
|
|||||||
GenerateKycUploadUrlsHandler,
|
GenerateKycUploadUrlsHandler,
|
||||||
UpdateProfileHandler,
|
UpdateProfileHandler,
|
||||||
VerifyEmailChangeHandler,
|
VerifyEmailChangeHandler,
|
||||||
|
VerifyPhoneChangeHandler,
|
||||||
RequestUserDeletionHandler,
|
RequestUserDeletionHandler,
|
||||||
CancelUserDeletionHandler,
|
CancelUserDeletionHandler,
|
||||||
ForceDeleteUserHandler,
|
ForceDeleteUserHandler,
|
||||||
|
|||||||
@@ -145,4 +145,9 @@ export class UserEntity extends AggregateRoot<string> {
|
|||||||
if (email !== undefined) this._email = email;
|
if (email !== undefined) this._email = email;
|
||||||
this.updatedAt = new Date();
|
this.updatedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updatePhone(phone: Phone): void {
|
||||||
|
this._phone = phone;
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export { UserRegisteredEvent } from './user-registered.event';
|
export { UserRegisteredEvent } from './user-registered.event';
|
||||||
export { AgentVerifiedEvent } from './agent-verified.event';
|
export { AgentVerifiedEvent } from './agent-verified.event';
|
||||||
export { EmailChangeRequestedEvent } from './email-change-requested.event';
|
export { EmailChangeRequestedEvent } from './email-change-requested.event';
|
||||||
|
export { PhoneChangeRequestedEvent } from './phone-change-requested.event';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -12,4 +12,5 @@ export { UserDeactivatedEvent } from './domain/events/user-deactivated.event';
|
|||||||
export { UserKycUpdatedEvent } from './domain/events/user-kyc-updated.event';
|
export { UserKycUpdatedEvent } from './domain/events/user-kyc-updated.event';
|
||||||
export { UserRegisteredEvent } from './domain/events/user-registered.event';
|
export { UserRegisteredEvent } from './domain/events/user-registered.event';
|
||||||
export { EmailChangeRequestedEvent } from './domain/events/email-change-requested.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';
|
export { USER_REPOSITORY, IUserRepository } from './domain/repositories/user.repository';
|
||||||
|
|||||||
@@ -16,9 +16,8 @@ import {
|
|||||||
EndpointRateLimit,
|
EndpointRateLimit,
|
||||||
EndpointRateLimitGuard,
|
EndpointRateLimitGuard,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
ValidationException,
|
|
||||||
} from '@modules/shared';
|
} 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 { LoginUserCommand } from '../../application/commands/login-user/login-user.command';
|
||||||
import { type LoginResult } from '../../application/commands/login-user/login-user.handler';
|
import { type LoginResult } from '../../application/commands/login-user/login-user.handler';
|
||||||
import { RefreshTokenCommand } from '../../application/commands/refresh-token/refresh-token.command';
|
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 { 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 { type VerifyEmailChangeResultDto } from '../../application/commands/verify-email-change/verify-email-change.handler';
|
||||||
import { VerifyKycCommand } from '../../application/commands/verify-kyc/verify-kyc.command';
|
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 { 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 { 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';
|
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 { type LocalStrategyResult } from '../../infrastructure/strategies/local.strategy';
|
||||||
import { CurrentUser } from '../decorators/current-user.decorator';
|
import { CurrentUser } from '../decorators/current-user.decorator';
|
||||||
import { Roles } from '../decorators/roles.decorator';
|
import { Roles } from '../decorators/roles.decorator';
|
||||||
|
import { type GenerateKycUploadUrlsDto } from '../dto/generate-kyc-upload-urls.dto';
|
||||||
import { LoginDto } from '../dto/login.dto';
|
import { LoginDto } from '../dto/login.dto';
|
||||||
import { type RefreshTokenDto } from '../dto/refresh-token.dto';
|
import { type RefreshTokenDto } from '../dto/refresh-token.dto';
|
||||||
import { type RegisterDto } from '../dto/register.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 UpdateProfileDto } from '../dto/update-profile.dto';
|
||||||
import { type VerifyEmailChangeDto } from '../dto/verify-email-change.dto';
|
import { type VerifyEmailChangeDto } from '../dto/verify-email-change.dto';
|
||||||
import { type VerifyKycDto } from '../dto/verify-kyc.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 { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
import { LocalAuthGuard } from '../guards/local-auth.guard';
|
import { LocalAuthGuard } from '../guards/local-auth.guard';
|
||||||
import { RolesGuard } from '../guards/roles.guard';
|
import { RolesGuard } from '../guards/roles.guard';
|
||||||
@@ -227,11 +231,29 @@ export class AuthController {
|
|||||||
@Body() dto: UpdateProfileDto,
|
@Body() dto: UpdateProfileDto,
|
||||||
): Promise<{ message: string; data: UpdateProfileResultDto }> {
|
): Promise<{ message: string; data: UpdateProfileResultDto }> {
|
||||||
const result: UpdateProfileResultDto = await this.commandBus.execute(
|
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 };
|
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)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Post('profile/verify-email')
|
@Post('profile/verify-email')
|
||||||
@ApiBearerAuth('JWT')
|
@ApiBearerAuth('JWT')
|
||||||
@@ -268,7 +290,7 @@ export class AuthController {
|
|||||||
@ApiResponse({ status: 400, description: 'Validation error' })
|
@ApiResponse({ status: 400, description: 'Validation error' })
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
async generateKycUploadUrls(
|
async generateKycUploadUrls(
|
||||||
@Body() body: { files: KycFileRequest[] },
|
@Body() body: GenerateKycUploadUrlsDto,
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
): Promise<{ field: string; uploadUrl: string; publicUrl: string; objectKey: string }[]> {
|
): Promise<{ field: string; uploadUrl: string; publicUrl: string; objectKey: string }[]> {
|
||||||
return this.commandBus.execute(
|
return this.commandBus.execute(
|
||||||
@@ -284,20 +306,9 @@ export class AuthController {
|
|||||||
@ApiResponse({ status: 400, description: 'Validation error' })
|
@ApiResponse({ status: 400, description: 'Validation error' })
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
async submitKyc(
|
async submitKyc(
|
||||||
@Body()
|
@Body() body: SubmitKycDto,
|
||||||
body: {
|
|
||||||
documentType: string;
|
|
||||||
documentNumber: string;
|
|
||||||
frontImageUrl: string;
|
|
||||||
backImageUrl?: string;
|
|
||||||
selfieUrl?: string;
|
|
||||||
},
|
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
): Promise<{ message: string }> {
|
): 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(
|
return this.commandBus.execute(
|
||||||
new SubmitKycCommand(
|
new SubmitKycCommand(
|
||||||
user.sub,
|
user.sub,
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ export { RegisterDto } from './register.dto';
|
|||||||
export { LoginDto } from './login.dto';
|
export { LoginDto } from './login.dto';
|
||||||
export { RefreshTokenDto } from './refresh-token.dto';
|
export { RefreshTokenDto } from './refresh-token.dto';
|
||||||
export { VerifyKycDto } from './verify-kyc.dto';
|
export { VerifyKycDto } from './verify-kyc.dto';
|
||||||
|
export { GenerateKycUploadUrlsDto, KycFileRequestDto } from './generate-kyc-upload-urls.dto';
|
||||||
export { VerifyMfaSetupDto, VerifyMfaChallengeDto, UseBackupCodeDto, DisableMfaDto } from './mfa.dto';
|
export { VerifyMfaSetupDto, VerifyMfaChallengeDto, UseBackupCodeDto, DisableMfaDto } from './mfa.dto';
|
||||||
|
|||||||
@@ -21,4 +21,13 @@ export class UpdateProfileDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEmail({}, { message: 'Email không hợp lệ' })
|
@IsEmail({}, { message: 'Email không hợp lệ' })
|
||||||
email?: string;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export class DeleteIndustrialListingCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly id: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export class GetIndustrialListingQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly id: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>>;
|
||||||
|
}
|
||||||
@@ -1,40 +1,58 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { CqrsModule } from '@nestjs/cqrs';
|
import { CqrsModule } from '@nestjs/cqrs';
|
||||||
import { SearchModule } from '@modules/search';
|
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 { 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 { 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 { 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 { GetIndustrialParkHandler } from './application/queries/get-industrial-park/get-industrial-park.handler';
|
||||||
import { IndustrialMarketHandler } from './application/queries/industrial-market/industrial-market.handler';
|
import { IndustrialMarketHandler } from './application/queries/industrial-market/industrial-market.handler';
|
||||||
import { IndustrialParkStatsHandler } from './application/queries/industrial-park-stats/industrial-park-stats.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 { 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 { 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 { PrismaIndustrialParkRepository } from './infrastructure/repositories/prisma-industrial-park.repository';
|
||||||
import { TypesenseIndustrialService } from './infrastructure/services/typesense-industrial.service';
|
import { TypesenseIndustrialService } from './infrastructure/services/typesense-industrial.service';
|
||||||
|
import { IndustrialListingsController } from './presentation/controllers/industrial-listings.controller';
|
||||||
import { IndustrialParksController } from './presentation/controllers/industrial-parks.controller';
|
import { IndustrialParksController } from './presentation/controllers/industrial-parks.controller';
|
||||||
|
|
||||||
const CommandHandlers = [
|
const CommandHandlers = [
|
||||||
CreateIndustrialParkHandler,
|
CreateIndustrialParkHandler,
|
||||||
UpdateIndustrialParkHandler,
|
UpdateIndustrialParkHandler,
|
||||||
|
CreateIndustrialListingHandler,
|
||||||
|
UpdateIndustrialListingHandler,
|
||||||
|
DeleteIndustrialListingHandler,
|
||||||
];
|
];
|
||||||
|
|
||||||
const QueryHandlers = [
|
const QueryHandlers = [
|
||||||
|
AnalyzeIndustrialLocationHandler,
|
||||||
|
EstimateIndustrialRentHandler,
|
||||||
GetIndustrialParkHandler,
|
GetIndustrialParkHandler,
|
||||||
ListIndustrialParksHandler,
|
ListIndustrialParksHandler,
|
||||||
CompareIndustrialParksHandler,
|
CompareIndustrialParksHandler,
|
||||||
IndustrialParkStatsHandler,
|
IndustrialParkStatsHandler,
|
||||||
IndustrialMarketHandler,
|
IndustrialMarketHandler,
|
||||||
|
GetIndustrialListingHandler,
|
||||||
|
ListIndustrialListingsHandler,
|
||||||
];
|
];
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CqrsModule, SearchModule],
|
imports: [CqrsModule, SearchModule],
|
||||||
controllers: [IndustrialParksController],
|
controllers: [IndustrialParksController, IndustrialListingsController],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: INDUSTRIAL_PARK_REPOSITORY, useClass: PrismaIndustrialParkRepository },
|
{ provide: INDUSTRIAL_PARK_REPOSITORY, useClass: PrismaIndustrialParkRepository },
|
||||||
|
{ provide: INDUSTRIAL_LISTING_REPOSITORY, useClass: PrismaIndustrialListingRepository },
|
||||||
TypesenseIndustrialService,
|
TypesenseIndustrialService,
|
||||||
...CommandHandlers,
|
...CommandHandlers,
|
||||||
...QueryHandlers,
|
...QueryHandlers,
|
||||||
],
|
],
|
||||||
exports: [INDUSTRIAL_PARK_REPOSITORY, TypesenseIndustrialService],
|
exports: [INDUSTRIAL_PARK_REPOSITORY, INDUSTRIAL_LISTING_REPOSITORY, TypesenseIndustrialService],
|
||||||
})
|
})
|
||||||
export class IndustrialModule {}
|
export class IndustrialModule {}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -88,6 +88,28 @@ interface RawIndustrialPark {
|
|||||||
createdAt: Date;
|
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()
|
@Injectable()
|
||||||
export class TypesenseIndustrialService implements OnModuleInit {
|
export class TypesenseIndustrialService implements OnModuleInit {
|
||||||
private client: TypesenseClient | null = null;
|
private client: TypesenseClient | null = null;
|
||||||
@@ -103,6 +125,7 @@ export class TypesenseIndustrialService implements OnModuleInit {
|
|||||||
this.client = this.typesenseClient.getClient();
|
this.client = this.typesenseClient.getClient();
|
||||||
await this.ensureCollections();
|
await this.ensureCollections();
|
||||||
await this.syncParks();
|
await this.syncParks();
|
||||||
|
await this.syncListings();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Typesense industrial init failed (non-fatal): ${err}`, 'TypesenseIndustrial');
|
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> {
|
async indexPark(parkId: string): Promise<void> {
|
||||||
if (!this.client) return;
|
if (!this.client) return;
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,15 +4,19 @@ import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagg
|
|||||||
import { UserRole } from '@prisma/client';
|
import { UserRole } from '@prisma/client';
|
||||||
import { JwtAuthGuard, Roles, RolesGuard } from '@modules/auth';
|
import { JwtAuthGuard, Roles, RolesGuard } from '@modules/auth';
|
||||||
import { NotFoundException } from '@modules/shared';
|
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 { 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 { UpdateIndustrialParkCommand } from '../../application/commands/update-industrial-park/update-industrial-park.command';
|
||||||
import { CompareIndustrialParksQuery } from '../../application/queries/compare-industrial-parks/compare-industrial-parks.query';
|
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 { GetIndustrialParkQuery } from '../../application/queries/get-industrial-park/get-industrial-park.query';
|
||||||
import { IndustrialMarketQuery } from '../../application/queries/industrial-market/industrial-market.query';
|
import { IndustrialMarketQuery } from '../../application/queries/industrial-market/industrial-market.query';
|
||||||
import { IndustrialParkStatsQuery } from '../../application/queries/industrial-park-stats/industrial-park-stats.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 { 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 CompareIndustrialParksDto } from '../dto/compare-industrial-parks.dto';
|
||||||
import { type CreateIndustrialParkDto } from '../dto/create-industrial-park.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 SearchIndustrialParksDto } from '../dto/search-industrial-parks.dto';
|
||||||
import { type UpdateIndustrialParkDto } from '../dto/update-industrial-park.dto';
|
import { type UpdateIndustrialParkDto } from '../dto/update-industrial-park.dto';
|
||||||
|
|
||||||
@@ -78,6 +82,38 @@ export class IndustrialParksController {
|
|||||||
return this.queryBus.execute(new IndustrialMarketQuery());
|
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 ───────────────────────────────────────────────
|
// ── Admin endpoints ───────────────────────────────────────────────
|
||||||
|
|
||||||
@ApiOperation({ summary: 'Tạo KCN (admin)', description: 'Tạo mới khu công nghiệp' })
|
@ApiOperation({ summary: 'Tạo KCN (admin)', description: 'Tạo mới khu công nghiệp' })
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>[];
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export class RecordPriceHistoryHandler implements IEventHandler<ListingPriceChan
|
|||||||
listingId: event.aggregateId,
|
listingId: event.aggregateId,
|
||||||
oldPrice: event.oldPrice,
|
oldPrice: event.oldPrice,
|
||||||
newPrice: event.newPrice,
|
newPrice: event.newPrice,
|
||||||
|
source: event.source,
|
||||||
changedAt: event.occurredAt,
|
changedAt: event.occurredAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface PriceHistoryItem {
|
|||||||
id: string;
|
id: string;
|
||||||
oldPrice: bigint;
|
oldPrice: bigint;
|
||||||
newPrice: bigint;
|
newPrice: bigint;
|
||||||
|
source: string;
|
||||||
changedAt: Date;
|
changedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ export class GetPriceHistoryHandler implements IQueryHandler<GetPriceHistoryQuer
|
|||||||
id: true,
|
id: true,
|
||||||
oldPrice: true,
|
oldPrice: true,
|
||||||
newPrice: true,
|
newPrice: true,
|
||||||
|
source: true,
|
||||||
changedAt: true,
|
changedAt: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { ListingApprovedEvent } from '../events/listing-approved.event';
|
import { ListingApprovedEvent } from '../events/listing-approved.event';
|
||||||
import { ListingCreatedEvent } from '../events/listing-created.event';
|
import { ListingCreatedEvent } from '../events/listing-created.event';
|
||||||
|
import { ListingPriceChangedEvent } from '../events/listing-price-changed.event';
|
||||||
import { ListingSoldEvent } from '../events/listing-sold.event';
|
import { ListingSoldEvent } from '../events/listing-sold.event';
|
||||||
import { ListingStatusChangedEvent } from '../events/listing-status-changed.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', () => {
|
describe('ListingStatusChangedEvent', () => {
|
||||||
it('creates event with correct properties', () => {
|
it('creates event with correct properties', () => {
|
||||||
const event = new ListingStatusChangedEvent('listing-1', 'prop-1', 'DRAFT', 'PENDING_REVIEW');
|
const event = new ListingStatusChangedEvent('listing-1', 'prop-1', 'DRAFT', 'PENDING_REVIEW');
|
||||||
|
|||||||
@@ -142,6 +142,33 @@ describe('ListingEntity', () => {
|
|||||||
const fields = listing.updateContent({});
|
const fields = listing.updateContent({});
|
||||||
expect(fields).toEqual([]);
|
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', () => {
|
describe('markEditedForReModeration', () => {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { DomainEvent } from '@modules/shared';
|
import type { DomainEvent } from '@modules/shared';
|
||||||
|
|
||||||
|
export type PriceChangeSource = 'manual_update' | 'admin_override' | 'market_adjustment';
|
||||||
|
|
||||||
export class ListingPriceChangedEvent implements DomainEvent {
|
export class ListingPriceChangedEvent implements DomainEvent {
|
||||||
readonly eventName = 'listing.price_changed';
|
readonly eventName = 'listing.price_changed';
|
||||||
readonly occurredAt = new Date();
|
readonly occurredAt = new Date();
|
||||||
@@ -8,5 +10,6 @@ export class ListingPriceChangedEvent implements DomainEvent {
|
|||||||
public readonly aggregateId: string,
|
public readonly aggregateId: string,
|
||||||
public readonly oldPrice: bigint,
|
public readonly oldPrice: bigint,
|
||||||
public readonly newPrice: bigint,
|
public readonly newPrice: bigint,
|
||||||
|
public readonly source: PriceChangeSource = 'manual_update',
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,19 @@ export { ListingsModule } from './listings.module';
|
|||||||
export { ListingEntity, type ListingProps } from './domain/entities/listing.entity';
|
export { ListingEntity, type ListingProps } from './domain/entities/listing.entity';
|
||||||
export { ListingCreatedEvent } from './domain/events/listing-created.event';
|
export { ListingCreatedEvent } from './domain/events/listing-created.event';
|
||||||
export { ModerateListingCommand } from './application/commands/moderate-listing/moderate-listing.command';
|
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 { LISTING_REPOSITORY, IListingRepository, type ListingSearchParams, type PaginatedResult } from './domain/repositories/listing.repository';
|
||||||
export { ListingPriceChangedEvent } from './domain/events/listing-price-changed.event';
|
export { ListingPriceChangedEvent } from './domain/events/listing-price-changed.event';
|
||||||
export { ListingSoldEvent } from './domain/events/listing-sold.event';
|
export { ListingSoldEvent } from './domain/events/listing-sold.event';
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { CqrsModule } from '@nestjs/cqrs';
|
import { CqrsModule } from '@nestjs/cqrs';
|
||||||
import { MulterModule } from '@nestjs/platform-express';
|
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 { CreateListingHandler } from './application/commands/create-listing/create-listing.handler';
|
||||||
import { FeatureListingHandler } from './application/commands/feature-listing/feature-listing.handler';
|
import { FeatureListingHandler } from './application/commands/feature-listing/feature-listing.handler';
|
||||||
import { ModerateListingHandler } from './application/commands/moderate-listing/moderate-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 { UpdateListingHandler } from './application/commands/update-listing/update-listing.handler';
|
||||||
import { UpdateListingStatusHandler } from './application/commands/update-listing-status/update-listing-status.handler';
|
import { UpdateListingStatusHandler } from './application/commands/update-listing-status/update-listing-status.handler';
|
||||||
import { UploadMediaHandler } from './application/commands/upload-media/upload-media.handler';
|
import { UploadMediaHandler } from './application/commands/upload-media/upload-media.handler';
|
||||||
@@ -28,6 +30,8 @@ import { ListingsController } from './presentation/controllers/listings.controll
|
|||||||
const CommandHandlers = [
|
const CommandHandlers = [
|
||||||
CreateListingHandler,
|
CreateListingHandler,
|
||||||
FeatureListingHandler,
|
FeatureListingHandler,
|
||||||
|
PromoteFeaturedListingHandler,
|
||||||
|
AdminFeatureListingHandler,
|
||||||
UpdateListingHandler,
|
UpdateListingHandler,
|
||||||
UpdateListingStatusHandler,
|
UpdateListingStatusHandler,
|
||||||
UploadMediaHandler,
|
UploadMediaHandler,
|
||||||
|
|||||||
@@ -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', () => {
|
describe('updateStatus', () => {
|
||||||
it('should execute UpdateListingStatusCommand via command bus', async () => {
|
it('should execute UpdateListingStatusCommand via command bus', async () => {
|
||||||
mockCommandBus.execute.mockResolvedValue({ status: 'ACTIVE' });
|
mockCommandBus.execute.mockResolvedValue({ status: 'ACTIVE' });
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import type { CreateListingResult } from '../../application/commands/create-list
|
|||||||
import { FeatureListingCommand } from '../../application/commands/feature-listing/feature-listing.command';
|
import { FeatureListingCommand } from '../../application/commands/feature-listing/feature-listing.command';
|
||||||
import type { FeatureListingResult } from '../../application/commands/feature-listing/feature-listing.handler';
|
import type { FeatureListingResult } from '../../application/commands/feature-listing/feature-listing.handler';
|
||||||
import { ModerateListingCommand } from '../../application/commands/moderate-listing/moderate-listing.command';
|
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 { UpdateListingCommand } from '../../application/commands/update-listing/update-listing.command';
|
||||||
import type { UpdateListingResult } from '../../application/commands/update-listing/update-listing.handler';
|
import type { UpdateListingResult } from '../../application/commands/update-listing/update-listing.handler';
|
||||||
import { UpdateListingStatusCommand } from '../../application/commands/update-listing-status/update-listing-status.command';
|
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 { CreateListingDto } from '../dto/create-listing.dto';
|
||||||
import type { FeatureListingDto } from '../dto/feature-listing.dto';
|
import type { FeatureListingDto } from '../dto/feature-listing.dto';
|
||||||
import type { ModerateListingDto } from '../dto/moderate-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 SearchListingsDto } from '../dto/search-listings.dto';
|
||||||
import type { UpdateListingStatusDto } from '../dto/update-listing-status.dto';
|
import type { UpdateListingStatusDto } from '../dto/update-listing-status.dto';
|
||||||
import type { UpdateListingDto } from '../dto/update-listing.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),
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { McpTransportController } from './presentation/mcp-transport.controller'
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
McpCoreModule.forRoot({
|
McpCoreModule.forRoot({
|
||||||
aiServiceBaseUrl: process.env['AI_SERVICE_URL'] || 'http://localhost:8000',
|
aiServiceBaseUrl: process.env['AI_SERVICE_URL'] || 'http://localhost:8000',
|
||||||
|
apiBaseUrl: process.env['API_BASE_URL'] || 'http://localhost:3001/api/v1',
|
||||||
typesenseCollectionName: 'listings',
|
typesenseCollectionName: 'listings',
|
||||||
skipDefaultController: true,
|
skipDefaultController: true,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ describe('MetricsService', () => {
|
|||||||
let mockSearchQueriesCounter: { inc: ReturnType<typeof vi.fn> };
|
let mockSearchQueriesCounter: { inc: ReturnType<typeof vi.fn> };
|
||||||
let mockRequestDurationHistogram: { observe: ReturnType<typeof vi.fn> };
|
let mockRequestDurationHistogram: { observe: ReturnType<typeof vi.fn> };
|
||||||
let mockHttpRequestsCounter: { inc: 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(() => {
|
beforeEach(() => {
|
||||||
mockListingsCreatedCounter = { inc: vi.fn() };
|
mockListingsCreatedCounter = { inc: vi.fn() };
|
||||||
@@ -17,6 +22,8 @@ describe('MetricsService', () => {
|
|||||||
mockSearchQueriesCounter = { inc: vi.fn() };
|
mockSearchQueriesCounter = { inc: vi.fn() };
|
||||||
mockRequestDurationHistogram = { observe: vi.fn() };
|
mockRequestDurationHistogram = { observe: vi.fn() };
|
||||||
mockHttpRequestsCounter = { inc: vi.fn() };
|
mockHttpRequestsCounter = { inc: vi.fn() };
|
||||||
|
mockWsConnectedClientsGauge = { inc: vi.fn(), set: vi.fn() };
|
||||||
|
mockWsMessagesCounter = { inc: vi.fn() };
|
||||||
|
|
||||||
service = new MetricsService(
|
service = new MetricsService(
|
||||||
mockListingsCreatedCounter as unknown as Counter,
|
mockListingsCreatedCounter as unknown as Counter,
|
||||||
@@ -25,6 +32,8 @@ describe('MetricsService', () => {
|
|||||||
mockSearchQueriesCounter as unknown as Counter,
|
mockSearchQueriesCounter as unknown as Counter,
|
||||||
mockRequestDurationHistogram as unknown as Histogram,
|
mockRequestDurationHistogram as unknown as Histogram,
|
||||||
mockHttpRequestsCounter as unknown as Counter,
|
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' }),
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
GOODGO_SEARCH_QUERIES_TOTAL,
|
GOODGO_SEARCH_QUERIES_TOTAL,
|
||||||
GOODGO_API_REQUEST_DURATION,
|
GOODGO_API_REQUEST_DURATION,
|
||||||
HTTP_REQUESTS_TOTAL,
|
HTTP_REQUESTS_TOTAL,
|
||||||
|
GOODGO_WS_CONNECTED_CLIENTS,
|
||||||
|
GOODGO_WS_MESSAGES_TOTAL,
|
||||||
WEB_VITALS_LCP,
|
WEB_VITALS_LCP,
|
||||||
WEB_VITALS_FCP,
|
WEB_VITALS_FCP,
|
||||||
WEB_VITALS_CLS,
|
WEB_VITALS_CLS,
|
||||||
@@ -31,6 +33,10 @@ export class MetricsService {
|
|||||||
private readonly requestDurationHistogram: Histogram,
|
private readonly requestDurationHistogram: Histogram,
|
||||||
@InjectMetric(HTTP_REQUESTS_TOTAL)
|
@InjectMetric(HTTP_REQUESTS_TOTAL)
|
||||||
private readonly httpRequestsCounter: Counter,
|
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)
|
@InjectMetric(WEB_VITALS_LCP)
|
||||||
private readonly lcpHistogram: Histogram,
|
private readonly lcpHistogram: Histogram,
|
||||||
@InjectMetric(WEB_VITALS_FCP)
|
@InjectMetric(WEB_VITALS_FCP)
|
||||||
@@ -81,6 +87,25 @@ export class MetricsService {
|
|||||||
this.httpRequestsCounter.inc(labels);
|
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. */
|
/** Map metric name → the correct histogram. */
|
||||||
private readonly vitalHistograms: Record<string, Histogram | undefined> = {};
|
private readonly vitalHistograms: Record<string, Histogram | undefined> = {};
|
||||||
|
|
||||||
|
|||||||
@@ -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 DB_POOL_ACTIVE_CONNECTIONS = 'db_pool_active_connections';
|
||||||
export const SEARCH_QUERY_DURATION = 'search_query_duration_seconds';
|
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 ──
|
// ── Web Vitals / RUM Metrics ──
|
||||||
export const WEB_VITALS_LCP = 'goodgo_web_vitals_lcp_seconds';
|
export const WEB_VITALS_LCP = 'goodgo_web_vitals_lcp_seconds';
|
||||||
export const WEB_VITALS_FCP = 'goodgo_web_vitals_fcp_seconds';
|
export const WEB_VITALS_FCP = 'goodgo_web_vitals_fcp_seconds';
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
DB_QUERY_DURATION,
|
DB_QUERY_DURATION,
|
||||||
DB_POOL_ACTIVE_CONNECTIONS,
|
DB_POOL_ACTIVE_CONNECTIONS,
|
||||||
SEARCH_QUERY_DURATION,
|
SEARCH_QUERY_DURATION,
|
||||||
|
GOODGO_WS_CONNECTED_CLIENTS,
|
||||||
|
GOODGO_WS_MESSAGES_TOTAL,
|
||||||
WEB_VITALS_LCP,
|
WEB_VITALS_LCP,
|
||||||
WEB_VITALS_FCP,
|
WEB_VITALS_FCP,
|
||||||
WEB_VITALS_CLS,
|
WEB_VITALS_CLS,
|
||||||
@@ -83,6 +85,18 @@ import { HttpMetricsInterceptor } from './presentation/interceptors/http-metrics
|
|||||||
labelNames: ['plan'],
|
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 ──
|
// ── Services & Interceptors ──
|
||||||
MetricsService,
|
MetricsService,
|
||||||
HttpMetricsInterceptor,
|
HttpMetricsInterceptor,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -14,3 +14,9 @@ export {
|
|||||||
NotificationChannel,
|
NotificationChannel,
|
||||||
ALL_CHANNELS,
|
ALL_CHANNELS,
|
||||||
} from './value-objects/notification-channel.vo';
|
} from './value-objects/notification-channel.vo';
|
||||||
|
export {
|
||||||
|
SMS_NOTIFICATION_CHANNEL,
|
||||||
|
type NotificationChannelPort,
|
||||||
|
type SendChannelMessageDto,
|
||||||
|
type SendChannelMessageResult,
|
||||||
|
} from './ports/notification-channel.port';
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -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
Reference in New Issue
Block a user