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",
|
||||
"@sentry/nestjs": "^10.47.0",
|
||||
"@sentry/profiling-node": "^10.47.0",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"@willsoto/nestjs-prometheus": "^6.1.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bullmq": "^5.74.1",
|
||||
|
||||
@@ -8,11 +8,10 @@ import './instrument';
|
||||
|
||||
import { RequestMethod, ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { IoAdapter } from '@nestjs/platform-socket.io';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import helmet from 'helmet';
|
||||
import { LoggerService, validateEnv } from '@modules/shared';
|
||||
import { LoggerService, RedisIoAdapter, validateEnv } from '@modules/shared';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
@@ -60,7 +59,11 @@ async function bootstrap() {
|
||||
});
|
||||
|
||||
// ── WebSocket Adapter (Socket.IO) ──
|
||||
app.useWebSocketAdapter(new IoAdapter(app));
|
||||
// Redis pub/sub fan-out for multi-instance broadcasts; falls back to the
|
||||
// in-memory IoAdapter when Redis is unreachable (single-node / local dev).
|
||||
const wsAdapter = new RedisIoAdapter(app);
|
||||
await wsAdapter.connectToRedis();
|
||||
app.useWebSocketAdapter(wsAdapter);
|
||||
|
||||
// ── Security Headers (Helmet) ──
|
||||
app.use(
|
||||
|
||||
@@ -2,13 +2,19 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Ip,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam, ApiQuery } from '@nestjs/swagger';
|
||||
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
||||
import {
|
||||
AdminFeatureListingCommand,
|
||||
type AdminFeatureListingResult,
|
||||
} from '@modules/listings';
|
||||
import { ApproveKycCommand } from '../../application/commands/approve-kyc/approve-kyc.command';
|
||||
import { type ApproveKycResult } from '../../application/commands/approve-kyc/approve-kyc.handler';
|
||||
import { ApproveListingCommand } from '../../application/commands/approve-listing/approve-listing.command';
|
||||
@@ -25,6 +31,7 @@ import {
|
||||
type ModerationQueueResult,
|
||||
type KycQueueResult,
|
||||
} from '../../domain/repositories/admin-query.repository';
|
||||
import { type AdminFeatureListingDto } from '../dto/admin-feature-listing.dto';
|
||||
import { type ApproveKycDto } from '../dto/approve-kyc.dto';
|
||||
import { type ApproveListingDto } from '../dto/approve-listing.dto';
|
||||
import { type BulkModerateDto } from '../dto/bulk-moderate.dto';
|
||||
@@ -105,6 +112,33 @@ export class AdminModerationController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post('listings/:id/feature')
|
||||
@ApiOperation({
|
||||
summary: 'Admin: feature or unfeature a listing manually (audited, no payment)',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'Listing UUID' })
|
||||
@ApiResponse({ status: 201, description: 'Listing featured state updated successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Validation error' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized – missing or invalid JWT' })
|
||||
@ApiResponse({ status: 403, description: 'Forbidden – requires ADMIN role' })
|
||||
async adminFeatureListing(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: AdminFeatureListingDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Ip() ip: string,
|
||||
): Promise<AdminFeatureListingResult> {
|
||||
return this.commandBus.execute(
|
||||
new AdminFeatureListingCommand(
|
||||
id,
|
||||
user.sub,
|
||||
dto.action,
|
||||
dto.durationDays ?? null,
|
||||
dto.reason,
|
||||
ip ?? null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── KYC ──
|
||||
|
||||
@Get('kyc')
|
||||
|
||||
@@ -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 { ListingCreatedModerationHandler } from './application/event-handlers/listing-created-moderation.handler';
|
||||
import { BatchValuationHandler } from './application/queries/batch-valuation/batch-valuation.handler';
|
||||
import { IndustrialValuationHandler } from './application/queries/industrial-valuation/industrial-valuation.handler';
|
||||
import { GetDistrictStatsHandler } from './application/queries/get-district-stats/get-district-stats.handler';
|
||||
import { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap.handler';
|
||||
import { GetMarketReportHandler } from './application/queries/get-market-report/get-market-report.handler';
|
||||
@@ -22,9 +23,13 @@ import { PrismaValuationRepository } from './infrastructure/repositories/prisma-
|
||||
import { AI_SERVICE_CLIENT, AiServiceClient } from './infrastructure/services/ai-service.client';
|
||||
import { HttpAVMService } from './infrastructure/services/http-avm.service';
|
||||
import { MarketIndexCronService } from './infrastructure/services/market-index-cron.service';
|
||||
import { NeighborhoodScoreServiceImpl } from './infrastructure/services/neighborhood-score.service';
|
||||
import {
|
||||
HttpNeighborhoodScoreService,
|
||||
PrismaNeighborhoodScoreService,
|
||||
} from './infrastructure/services/neighborhood-score.service';
|
||||
import { PrismaAVMService } from './infrastructure/services/prisma-avm.service';
|
||||
import { AnalyticsController } from './presentation/controllers/analytics.controller';
|
||||
import { AvmController } from './presentation/controllers/avm.controller';
|
||||
|
||||
const CommandHandlers = [
|
||||
TrackEventHandler,
|
||||
@@ -42,6 +47,7 @@ const QueryHandlers = [
|
||||
ValuationHistoryHandler,
|
||||
ValuationComparisonHandler,
|
||||
GetNeighborhoodScoreHandler,
|
||||
IndustrialValuationHandler,
|
||||
];
|
||||
|
||||
const EventHandlers = [
|
||||
@@ -50,7 +56,7 @@ const EventHandlers = [
|
||||
|
||||
@Module({
|
||||
imports: [CqrsModule],
|
||||
controllers: [AnalyticsController],
|
||||
controllers: [AnalyticsController, AvmController],
|
||||
providers: [
|
||||
// AI service client
|
||||
{ provide: AI_SERVICE_CLIENT, useClass: AiServiceClient },
|
||||
@@ -63,8 +69,9 @@ const EventHandlers = [
|
||||
PrismaAVMService,
|
||||
{ provide: AVM_SERVICE, useClass: HttpAVMService },
|
||||
|
||||
// Neighborhood scoring
|
||||
{ provide: NEIGHBORHOOD_SCORE_SERVICE, useClass: NeighborhoodScoreServiceImpl },
|
||||
// Neighborhood scoring: HTTP proxy → Python AI service, falls back to Prisma scoring
|
||||
PrismaNeighborhoodScoreService,
|
||||
{ provide: NEIGHBORHOOD_SCORE_SERVICE, useClass: HttpNeighborhoodScoreService },
|
||||
|
||||
// Cron
|
||||
MarketIndexCronService,
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
return this.cache.getOrSet(
|
||||
return await this.cache.getOrSet(
|
||||
cacheKey,
|
||||
async () => {
|
||||
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(),
|
||||
);
|
||||
|
||||
return this.cache.getOrSet(
|
||||
return await this.cache.getOrSet(
|
||||
cacheKey,
|
||||
() => this.buildComparison(query.propertyIds),
|
||||
CacheTTL.MARKET_DATA,
|
||||
|
||||
@@ -31,7 +31,7 @@ export class ValuationHistoryHandler implements IQueryHandler<ValuationHistoryQu
|
||||
query.limit.toString(),
|
||||
);
|
||||
|
||||
return this.cache.getOrSet(
|
||||
return await this.cache.getOrSet(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const entities = await this.valuationRepo.findByPropertyId(query.propertyId);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
export interface AiIndustrialPredictRequest {
|
||||
province: string;
|
||||
region: string;
|
||||
park_occupancy_rate: number;
|
||||
park_area_ha: number;
|
||||
park_age_years: number;
|
||||
distance_to_port_km: number;
|
||||
distance_to_airport_km: number;
|
||||
distance_to_highway_km: number;
|
||||
property_type: string;
|
||||
area_m2: number;
|
||||
ceiling_height_m?: number;
|
||||
floor_load_ton_m2?: number;
|
||||
power_capacity_kva?: number;
|
||||
building_coverage?: number;
|
||||
loading_docks?: number;
|
||||
zoning?: string;
|
||||
industry_demand_index?: number;
|
||||
fdi_province_musd?: number;
|
||||
labor_cost_province_vnd?: number;
|
||||
logistics_connectivity_score?: number;
|
||||
}
|
||||
|
||||
export interface AiIndustrialComparable {
|
||||
park_name: string;
|
||||
province: string;
|
||||
property_type: string;
|
||||
area_m2: number;
|
||||
rent_usd_m2: number;
|
||||
similarity_score: number;
|
||||
}
|
||||
|
||||
export interface AiIndustrialFeatureImportance {
|
||||
feature: string;
|
||||
importance: number;
|
||||
}
|
||||
|
||||
export interface AiIndustrialPredictResponse {
|
||||
estimated_rent_usd_m2: number;
|
||||
confidence: number;
|
||||
rent_range_low_usd_m2: number;
|
||||
rent_range_high_usd_m2: number;
|
||||
annual_rent_usd_m2: number;
|
||||
total_monthly_rent_usd: number;
|
||||
comparables: AiIndustrialComparable[];
|
||||
drivers: AiIndustrialFeatureImportance[];
|
||||
model_version: string;
|
||||
}
|
||||
|
||||
export interface AiModerationRequest {
|
||||
text: string;
|
||||
context?: string;
|
||||
@@ -42,11 +91,42 @@ export interface AiModerationResponse {
|
||||
cleaned_text: string | null;
|
||||
}
|
||||
|
||||
export interface AiNeighborhoodPOICounts {
|
||||
education: number;
|
||||
healthcare: number;
|
||||
transport: number;
|
||||
shopping: number;
|
||||
greenery: number;
|
||||
safety: number;
|
||||
}
|
||||
|
||||
export interface AiNeighborhoodScoreRequest {
|
||||
district: string;
|
||||
city: string;
|
||||
poi_counts: AiNeighborhoodPOICounts;
|
||||
}
|
||||
|
||||
export interface AiNeighborhoodScoreResponse {
|
||||
district: string;
|
||||
city: string;
|
||||
education_score: number;
|
||||
healthcare_score: number;
|
||||
transport_score: number;
|
||||
shopping_score: number;
|
||||
greenery_score: number;
|
||||
safety_score: number;
|
||||
total_score: number;
|
||||
poi_counts: Record<string, number>;
|
||||
algorithm_version: string;
|
||||
}
|
||||
|
||||
export const AI_SERVICE_CLIENT = Symbol('AI_SERVICE_CLIENT');
|
||||
|
||||
export interface IAiServiceClient {
|
||||
predict(req: AiPredictRequest): Promise<AiPredictResponse>;
|
||||
predictIndustrial(req: AiIndustrialPredictRequest): Promise<AiIndustrialPredictResponse>;
|
||||
moderate(req: AiModerationRequest): Promise<AiModerationResponse>;
|
||||
scoreNeighborhood(req: AiNeighborhoodScoreRequest): Promise<AiNeighborhoodScoreResponse>;
|
||||
isAvailable(): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -66,10 +146,20 @@ export class AiServiceClient implements IAiServiceClient {
|
||||
return this.post<AiPredictResponse>('/avm/predict', req);
|
||||
}
|
||||
|
||||
async predictIndustrial(req: AiIndustrialPredictRequest): Promise<AiIndustrialPredictResponse> {
|
||||
return this.post<AiIndustrialPredictResponse>('/avm/industrial/predict', req);
|
||||
}
|
||||
|
||||
async moderate(req: AiModerationRequest): Promise<AiModerationResponse> {
|
||||
return this.post<AiModerationResponse>('/moderation/check', req);
|
||||
}
|
||||
|
||||
async scoreNeighborhood(
|
||||
req: AiNeighborhoodScoreRequest,
|
||||
): Promise<AiNeighborhoodScoreResponse> {
|
||||
return this.post<AiNeighborhoodScoreResponse>('/neighborhood/score', req);
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/health`, {
|
||||
|
||||
@@ -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 INeighborhoodScoreService,
|
||||
type NeighborhoodScoreResult,
|
||||
} from '../../domain/services/neighborhood-score.service';
|
||||
import {
|
||||
AI_SERVICE_CLIENT,
|
||||
type AiNeighborhoodPOICounts,
|
||||
type IAiServiceClient,
|
||||
} from './ai-service.client';
|
||||
|
||||
/**
|
||||
* Scoring weights for each POI category.
|
||||
* Sum = 100 (total score is 0–100 weighted average).
|
||||
* Mirrors the Python heuristic in libs/ai-services/app/services/neighborhood_service.py.
|
||||
*/
|
||||
const CATEGORY_WEIGHTS = {
|
||||
education: 20,
|
||||
@@ -16,20 +23,20 @@ const CATEGORY_WEIGHTS = {
|
||||
shopping: 15,
|
||||
greenery: 15,
|
||||
safety: 10,
|
||||
};
|
||||
} as const;
|
||||
|
||||
/** POI types grouped by scoring category. */
|
||||
const CATEGORY_POI_TYPES: Record<string, string[]> = {
|
||||
education: ['SCHOOL', 'UNIVERSITY'],
|
||||
healthcare: ['HOSPITAL', 'CLINIC'],
|
||||
transport: ['METRO_STATION', 'BUS_STOP'],
|
||||
shopping: ['MALL', 'MARKET', 'SUPERMARKET'],
|
||||
greenery: ['PARK'],
|
||||
safety: ['POLICE_STATION', 'FIRE_STATION'],
|
||||
const CATEGORY_POI_TYPES: Record<keyof typeof CATEGORY_WEIGHTS, POIType[]> = {
|
||||
education: [POIType.SCHOOL, POIType.UNIVERSITY],
|
||||
healthcare: [POIType.HOSPITAL, POIType.CLINIC],
|
||||
transport: [POIType.METRO_STATION, POIType.BUS_STOP],
|
||||
shopping: [POIType.MALL, POIType.MARKET, POIType.SUPERMARKET],
|
||||
greenery: [POIType.PARK],
|
||||
safety: [POIType.POLICE_STATION, POIType.FIRE_STATION],
|
||||
};
|
||||
|
||||
/** Max count per category that yields a 10/10 score. */
|
||||
const MAX_COUNTS: Record<string, number> = {
|
||||
const MAX_COUNTS: Record<keyof typeof CATEGORY_WEIGHTS, number> = {
|
||||
education: 15,
|
||||
healthcare: 8,
|
||||
transport: 12,
|
||||
@@ -38,8 +45,11 @@ const MAX_COUNTS: Record<string, number> = {
|
||||
safety: 4,
|
||||
};
|
||||
|
||||
type CategoryKey = keyof typeof CATEGORY_WEIGHTS;
|
||||
const CATEGORY_KEYS = Object.keys(CATEGORY_WEIGHTS) as CategoryKey[];
|
||||
|
||||
@Injectable()
|
||||
export class NeighborhoodScoreServiceImpl implements INeighborhoodScoreService {
|
||||
export class PrismaNeighborhoodScoreService implements INeighborhoodScoreService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
@@ -52,91 +62,179 @@ export class NeighborhoodScoreServiceImpl implements INeighborhoodScoreService {
|
||||
|
||||
if (!existing) return null;
|
||||
|
||||
return {
|
||||
district: existing.district,
|
||||
city: existing.city,
|
||||
educationScore: existing.educationScore,
|
||||
healthcareScore: existing.healthcareScore,
|
||||
transportScore: existing.transportScore,
|
||||
shoppingScore: existing.shoppingScore,
|
||||
greeneryScore: existing.greeneryScore,
|
||||
safetyScore: existing.safetyScore,
|
||||
totalScore: existing.totalScore,
|
||||
poiCounts: existing.poiCounts as Record<string, number>,
|
||||
calculatedAt: existing.calculatedAt,
|
||||
};
|
||||
return mapRecord(existing);
|
||||
}
|
||||
|
||||
async calculateAndSave(district: string, city: string): Promise<NeighborhoodScoreResult> {
|
||||
// Count POIs per category for this district
|
||||
const poiCounts: Record<string, number> = {};
|
||||
const categoryScores: Record<string, number> = {};
|
||||
const counts = await countPOIs(this.prisma, district, city);
|
||||
const subScores = scoreFromCounts(counts);
|
||||
const totalScore = weightedTotal(subScores);
|
||||
|
||||
for (const [category, poiTypes] of Object.entries(CATEGORY_POI_TYPES)) {
|
||||
const count = await this.prisma.pOI.count({
|
||||
const result = await upsertScore(this.prisma, district, city, subScores, totalScore, counts);
|
||||
this.logger.log(
|
||||
`Neighborhood score (prisma) calculated: ${district}, ${city} → total=${result.totalScore}`,
|
||||
'NeighborhoodScoreService',
|
||||
);
|
||||
return mapRecord(result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the Python AI service to compute scores; falls back to local Prisma scoring
|
||||
* when the service is unavailable or the call times out. Persists to NeighborhoodScore.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HttpNeighborhoodScoreService implements INeighborhoodScoreService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
@Inject(AI_SERVICE_CLIENT) private readonly aiClient: IAiServiceClient,
|
||||
private readonly fallback: PrismaNeighborhoodScoreService,
|
||||
) {}
|
||||
|
||||
async getScore(district: string, city: string): Promise<NeighborhoodScoreResult | null> {
|
||||
return this.fallback.getScore(district, city);
|
||||
}
|
||||
|
||||
async calculateAndSave(district: string, city: string): Promise<NeighborhoodScoreResult> {
|
||||
const counts = await countPOIs(this.prisma, district, city);
|
||||
|
||||
try {
|
||||
const aiResult = await this.aiClient.scoreNeighborhood({
|
||||
district,
|
||||
city,
|
||||
poi_counts: counts,
|
||||
});
|
||||
|
||||
const subScores: Record<CategoryKey, number> = {
|
||||
education: aiResult.education_score,
|
||||
healthcare: aiResult.healthcare_score,
|
||||
transport: aiResult.transport_score,
|
||||
shopping: aiResult.shopping_score,
|
||||
greenery: aiResult.greenery_score,
|
||||
safety: aiResult.safety_score,
|
||||
};
|
||||
|
||||
const result = await upsertScore(
|
||||
this.prisma,
|
||||
district,
|
||||
city,
|
||||
subScores,
|
||||
aiResult.total_score,
|
||||
counts,
|
||||
);
|
||||
this.logger.log(
|
||||
`Neighborhood score (ai=${aiResult.algorithm_version}): ${district}, ${city} → total=${result.totalScore}`,
|
||||
'NeighborhoodScoreService',
|
||||
);
|
||||
return mapRecord(result);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`AI neighborhood score unavailable, falling back to prisma scoring: ${(err as Error).message}`,
|
||||
'NeighborhoodScoreService',
|
||||
);
|
||||
return this.fallback.calculateAndSave(district, city);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function countPOIs(
|
||||
prisma: PrismaService,
|
||||
district: string,
|
||||
city: string,
|
||||
): Promise<AiNeighborhoodPOICounts> {
|
||||
const entries = await Promise.all(
|
||||
CATEGORY_KEYS.map(async (cat) => {
|
||||
const count = await prisma.pOI.count({
|
||||
where: {
|
||||
district,
|
||||
city,
|
||||
type: { in: poiTypes as any },
|
||||
type: { in: CATEGORY_POI_TYPES[cat] },
|
||||
},
|
||||
});
|
||||
return [cat, count] as const;
|
||||
}),
|
||||
);
|
||||
|
||||
poiCounts[category] = count;
|
||||
// Score 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 {
|
||||
district: result.district,
|
||||
city: result.city,
|
||||
educationScore: result.educationScore,
|
||||
healthcareScore: result.healthcareScore,
|
||||
transportScore: result.transportScore,
|
||||
shoppingScore: result.shoppingScore,
|
||||
greeneryScore: result.greeneryScore,
|
||||
safetyScore: result.safetyScore,
|
||||
totalScore: result.totalScore,
|
||||
poiCounts: result.poiCounts as Record<string, number>,
|
||||
calculatedAt: result.calculatedAt,
|
||||
};
|
||||
}
|
||||
return Object.fromEntries(entries) as unknown as AiNeighborhoodPOICounts;
|
||||
}
|
||||
|
||||
function scoreFromCounts(counts: AiNeighborhoodPOICounts): Record<CategoryKey, number> {
|
||||
return Object.fromEntries(
|
||||
CATEGORY_KEYS.map((cat) => {
|
||||
const raw = counts[cat] ?? 0;
|
||||
const max = MAX_COUNTS[cat];
|
||||
return [cat, Math.min(10, (raw / max) * 10)];
|
||||
}),
|
||||
) as Record<CategoryKey, number>;
|
||||
}
|
||||
|
||||
function weightedTotal(subScores: Record<CategoryKey, number>): number {
|
||||
const sum = CATEGORY_KEYS.reduce(
|
||||
(acc, cat) => acc + (subScores[cat] * CATEGORY_WEIGHTS[cat]) / 10,
|
||||
0,
|
||||
);
|
||||
return Math.round(sum * 10) / 10;
|
||||
}
|
||||
|
||||
async function upsertScore(
|
||||
prisma: PrismaService,
|
||||
district: string,
|
||||
city: string,
|
||||
subScores: Record<CategoryKey, number>,
|
||||
totalScore: number,
|
||||
counts: AiNeighborhoodPOICounts,
|
||||
) {
|
||||
const calculatedAt = new Date();
|
||||
const data = {
|
||||
educationScore: subScores.education,
|
||||
healthcareScore: subScores.healthcare,
|
||||
transportScore: subScores.transport,
|
||||
shoppingScore: subScores.shopping,
|
||||
greeneryScore: subScores.greenery,
|
||||
safetyScore: subScores.safety,
|
||||
totalScore,
|
||||
poiCounts: counts as unknown as Record<string, number>,
|
||||
calculatedAt,
|
||||
};
|
||||
|
||||
return prisma.neighborhoodScore.upsert({
|
||||
where: { district_city: { district, city } },
|
||||
create: { district, city, ...data },
|
||||
update: data,
|
||||
});
|
||||
}
|
||||
|
||||
function mapRecord(record: {
|
||||
district: string;
|
||||
city: string;
|
||||
educationScore: number;
|
||||
healthcareScore: number;
|
||||
transportScore: number;
|
||||
shoppingScore: number;
|
||||
greeneryScore: number;
|
||||
safetyScore: number;
|
||||
totalScore: number;
|
||||
poiCounts: unknown;
|
||||
calculatedAt: Date;
|
||||
}): NeighborhoodScoreResult {
|
||||
return {
|
||||
district: record.district,
|
||||
city: record.city,
|
||||
educationScore: record.educationScore,
|
||||
healthcareScore: record.healthcareScore,
|
||||
transportScore: record.transportScore,
|
||||
shoppingScore: record.shoppingScore,
|
||||
greeneryScore: record.greeneryScore,
|
||||
safetyScore: record.safetyScore,
|
||||
totalScore: record.totalScore,
|
||||
poiCounts: record.poiCounts as Record<string, number>,
|
||||
calculatedAt: record.calculatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use HttpNeighborhoodScoreService (binds AI proxy + prisma fallback).
|
||||
* Kept exported for backward compatibility with callers/tests.
|
||||
*/
|
||||
export { PrismaNeighborhoodScoreService as NeighborhoodScoreServiceImpl };
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { type QueryBus } from '@nestjs/cqrs';
|
||||
import { BatchValuationQuery } from '../../application/queries/batch-valuation/batch-valuation.query';
|
||||
import { GetDistrictStatsQuery } from '../../application/queries/get-district-stats/get-district-stats.query';
|
||||
import { GetHeatmapQuery } from '../../application/queries/get-heatmap/get-heatmap.query';
|
||||
import { GetMarketReportQuery } from '../../application/queries/get-market-report/get-market-report.query';
|
||||
import { GetPriceTrendQuery } from '../../application/queries/get-price-trend/get-price-trend.query';
|
||||
import { GetValuationQuery } from '../../application/queries/get-valuation/get-valuation.query';
|
||||
import { ValuationComparisonQuery } from '../../application/queries/valuation-comparison/valuation-comparison.query';
|
||||
import { ValuationHistoryQuery } from '../../application/queries/valuation-history/valuation-history.query';
|
||||
import { AnalyticsController } from '../controllers/analytics.controller';
|
||||
|
||||
describe('AnalyticsController', () => {
|
||||
@@ -76,4 +80,80 @@ describe('AnalyticsController', () => {
|
||||
);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('getValuation executes GetValuationQuery with correct params', async () => {
|
||||
const expected = { estimatedPrice: '5000000000', confidence: 0.85 };
|
||||
mockQueryBus.execute.mockResolvedValue(expected);
|
||||
|
||||
const result = await controller.getValuation({
|
||||
propertyId: 'prop-123',
|
||||
latitude: undefined,
|
||||
longitude: undefined,
|
||||
areaM2: undefined,
|
||||
propertyType: undefined,
|
||||
} as any);
|
||||
|
||||
expect(mockQueryBus.execute).toHaveBeenCalledWith(
|
||||
new GetValuationQuery('prop-123', undefined, undefined, undefined, undefined),
|
||||
);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('batchValuation executes BatchValuationQuery with correct params', async () => {
|
||||
const expected = [
|
||||
{ propertyId: 'prop-1', valuation: { estimatedPrice: '5000000000' } },
|
||||
{ propertyId: 'prop-2', valuation: { estimatedPrice: '6000000000' } },
|
||||
];
|
||||
mockQueryBus.execute.mockResolvedValue(expected);
|
||||
|
||||
const result = await controller.batchValuation({
|
||||
propertyIds: ['prop-1', 'prop-2'],
|
||||
} as any);
|
||||
|
||||
expect(mockQueryBus.execute).toHaveBeenCalledWith(
|
||||
new BatchValuationQuery(['prop-1', 'prop-2']),
|
||||
);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('getValuationHistory executes ValuationHistoryQuery with correct params', async () => {
|
||||
const expected = { propertyId: 'prop-1', history: [], totalRecords: 0 };
|
||||
mockQueryBus.execute.mockResolvedValue(expected);
|
||||
|
||||
const result = await controller.getValuationHistory('prop-1', { limit: 25 } as any);
|
||||
|
||||
expect(mockQueryBus.execute).toHaveBeenCalledWith(
|
||||
new ValuationHistoryQuery('prop-1', 25),
|
||||
);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('getValuationHistory defaults limit to 50', async () => {
|
||||
const expected = { propertyId: 'prop-1', history: [], totalRecords: 0 };
|
||||
mockQueryBus.execute.mockResolvedValue(expected);
|
||||
|
||||
const result = await controller.getValuationHistory('prop-1', {} as any);
|
||||
|
||||
expect(mockQueryBus.execute).toHaveBeenCalledWith(
|
||||
new ValuationHistoryQuery('prop-1', 50),
|
||||
);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('compareValuations executes ValuationComparisonQuery with correct params', async () => {
|
||||
const expected = {
|
||||
properties: [],
|
||||
summary: { highestValue: null, lowestValue: null, averagePricePerM2: 0, averageConfidence: 0 },
|
||||
};
|
||||
mockQueryBus.execute.mockResolvedValue(expected);
|
||||
|
||||
const result = await controller.compareValuations({
|
||||
propertyIds: ['prop-1', 'prop-2', 'prop-3'],
|
||||
} as any);
|
||||
|
||||
expect(mockQueryBus.execute).toHaveBeenCalledWith(
|
||||
new ValuationComparisonQuery(['prop-1', 'prop-2', 'prop-3']),
|
||||
);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 { 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 { ValuationHistoryDto } from './valuation-history.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(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 avatarUrl?: string,
|
||||
public readonly email?: string,
|
||||
public readonly phoneNumber?: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
ValidationException,
|
||||
} from '@modules/shared';
|
||||
import { EmailChangeRequestedEvent } from '../../../domain/events/email-change-requested.event';
|
||||
import { PhoneChangeRequestedEvent } from '../../../domain/events/phone-change-requested.event';
|
||||
import { type IUserRepository, USER_REPOSITORY } from '../../../domain/repositories/user.repository';
|
||||
import { Email } from '../../../domain/value-objects/email.vo';
|
||||
import { Phone } from '../../../domain/value-objects/phone.vo';
|
||||
import { UpdateProfileCommand } from './update-profile.command';
|
||||
|
||||
/** TTL for email-change OTP codes stored in Redis (10 minutes). */
|
||||
@@ -22,12 +24,20 @@ const EMAIL_CHANGE_OTP_TTL = 600;
|
||||
/** Redis key prefix for pending email-change OTP. */
|
||||
export const EMAIL_CHANGE_OTP_PREFIX = 'auth:email_change_otp';
|
||||
|
||||
/** TTL for phone-change OTP codes stored in Redis (10 minutes). */
|
||||
const PHONE_CHANGE_OTP_TTL = 600;
|
||||
|
||||
/** Redis key prefix for pending phone-change OTP. */
|
||||
export const PHONE_CHANGE_OTP_PREFIX = 'auth:phone_change_otp';
|
||||
|
||||
export interface UpdateProfileResultDto {
|
||||
id: string;
|
||||
fullName: string;
|
||||
avatarUrl: string | null;
|
||||
email: string | null;
|
||||
phoneNumber: string;
|
||||
emailChangePending?: boolean;
|
||||
phoneChangePending?: boolean;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@@ -49,6 +59,7 @@ export class UpdateProfileHandler implements ICommandHandler<UpdateProfileComman
|
||||
}
|
||||
|
||||
let emailChangePending = false;
|
||||
let phoneChangePending = false;
|
||||
|
||||
// Validate and handle email change via OTP
|
||||
if (command.email !== undefined) {
|
||||
@@ -84,7 +95,41 @@ export class UpdateProfileHandler implements ICommandHandler<UpdateProfileComman
|
||||
}
|
||||
}
|
||||
|
||||
// Apply non-email fields immediately
|
||||
// Validate and handle phone change via SMS OTP
|
||||
if (command.phoneNumber !== undefined) {
|
||||
const phoneResult = Phone.create(command.phoneNumber);
|
||||
if (phoneResult.isErr) {
|
||||
throw new ValidationException(phoneResult.unwrapErr());
|
||||
}
|
||||
const phone = phoneResult.unwrap();
|
||||
|
||||
// Check if phone is actually changing
|
||||
if (user.phone.value !== phone.value) {
|
||||
// Check uniqueness
|
||||
const existingUser = await this.userRepo.findByPhone(phone.value);
|
||||
if (existingUser && existingUser.id !== command.userId) {
|
||||
throw new ConflictException('Số điện thoại đã được sử dụng bởi tài khoản khác');
|
||||
}
|
||||
|
||||
// Generate OTP and store pending change in Redis
|
||||
const otpCode = String(randomInt(100_000, 999_999));
|
||||
const payload = JSON.stringify({ newPhone: phone.value, code: otpCode });
|
||||
await this.redis.set(
|
||||
`${PHONE_CHANGE_OTP_PREFIX}:${command.userId}`,
|
||||
payload,
|
||||
PHONE_CHANGE_OTP_TTL,
|
||||
);
|
||||
|
||||
// Emit event so notifications module can send the SMS OTP
|
||||
this.eventBus.publish(
|
||||
new PhoneChangeRequestedEvent(command.userId, phone.value, otpCode),
|
||||
);
|
||||
|
||||
phoneChangePending = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply non-email / non-phone fields immediately
|
||||
user.updateProfile(command.fullName, command.avatarUrl, undefined);
|
||||
await this.userRepo.update(user);
|
||||
|
||||
@@ -97,7 +142,9 @@ export class UpdateProfileHandler implements ICommandHandler<UpdateProfileComman
|
||||
fullName: user.fullName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
email: user.email?.value ?? null,
|
||||
phoneNumber: user.phone.value,
|
||||
...(emailChangePending ? { emailChangePending: true } : {}),
|
||||
...(phoneChangePending ? { phoneChangePending: true } : {}),
|
||||
updatedAt: user.updatedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -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 { VerifyMfaChallengeHandler } from './application/commands/verify-mfa-challenge/verify-mfa-challenge.handler';
|
||||
import { VerifyMfaSetupHandler } from './application/commands/verify-mfa-setup/verify-mfa-setup.handler';
|
||||
import { VerifyPhoneChangeHandler } from './application/commands/verify-phone-change/verify-phone-change.handler';
|
||||
import { GetAgentByUserIdHandler } from './application/queries/get-agent-by-user-id/get-agent-by-user-id.handler';
|
||||
import { GetMfaStatusHandler } from './application/queries/get-mfa-status/get-mfa-status.handler';
|
||||
import { GetProfileHandler } from './application/queries/get-profile/get-profile.handler';
|
||||
@@ -55,6 +56,7 @@ const CommandHandlers = [
|
||||
GenerateKycUploadUrlsHandler,
|
||||
UpdateProfileHandler,
|
||||
VerifyEmailChangeHandler,
|
||||
VerifyPhoneChangeHandler,
|
||||
RequestUserDeletionHandler,
|
||||
CancelUserDeletionHandler,
|
||||
ForceDeleteUserHandler,
|
||||
|
||||
@@ -145,4 +145,9 @@ export class UserEntity extends AggregateRoot<string> {
|
||||
if (email !== undefined) this._email = email;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
updatePhone(phone: Phone): void {
|
||||
this._phone = phone;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { UserRegisteredEvent } from './user-registered.event';
|
||||
export { AgentVerifiedEvent } from './agent-verified.event';
|
||||
export { EmailChangeRequestedEvent } from './email-change-requested.event';
|
||||
export { PhoneChangeRequestedEvent } from './phone-change-requested.event';
|
||||
|
||||
@@ -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 { UserRegisteredEvent } from './domain/events/user-registered.event';
|
||||
export { EmailChangeRequestedEvent } from './domain/events/email-change-requested.event';
|
||||
export { PhoneChangeRequestedEvent } from './domain/events/phone-change-requested.event';
|
||||
export { USER_REPOSITORY, IUserRepository } from './domain/repositories/user.repository';
|
||||
|
||||
@@ -16,9 +16,8 @@ import {
|
||||
EndpointRateLimit,
|
||||
EndpointRateLimitGuard,
|
||||
UnauthorizedException,
|
||||
ValidationException,
|
||||
} from '@modules/shared';
|
||||
import { GenerateKycUploadUrlsCommand, type KycFileRequest } from '../../application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.command';
|
||||
import { GenerateKycUploadUrlsCommand } from '../../application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.command';
|
||||
import { LoginUserCommand } from '../../application/commands/login-user/login-user.command';
|
||||
import { type LoginResult } from '../../application/commands/login-user/login-user.handler';
|
||||
import { RefreshTokenCommand } from '../../application/commands/refresh-token/refresh-token.command';
|
||||
@@ -29,6 +28,8 @@ import { type UpdateProfileResultDto } from '../../application/commands/update-p
|
||||
import { VerifyEmailChangeCommand } from '../../application/commands/verify-email-change/verify-email-change.command';
|
||||
import { type VerifyEmailChangeResultDto } from '../../application/commands/verify-email-change/verify-email-change.handler';
|
||||
import { VerifyKycCommand } from '../../application/commands/verify-kyc/verify-kyc.command';
|
||||
import { VerifyPhoneChangeCommand } from '../../application/commands/verify-phone-change/verify-phone-change.command';
|
||||
import { type VerifyPhoneChangeResultDto } from '../../application/commands/verify-phone-change/verify-phone-change.handler';
|
||||
import { type AgentDto } from '../../application/queries/get-agent-by-user-id/get-agent-by-user-id.handler';
|
||||
import { GetAgentByUserIdQuery } from '../../application/queries/get-agent-by-user-id/get-agent-by-user-id.query';
|
||||
import { type UserProfileDto } from '../../application/queries/get-profile/get-profile.handler';
|
||||
@@ -37,12 +38,15 @@ import { type TokenService, type JwtPayload, type TokenPair } from '../../infras
|
||||
import { type LocalStrategyResult } from '../../infrastructure/strategies/local.strategy';
|
||||
import { CurrentUser } from '../decorators/current-user.decorator';
|
||||
import { Roles } from '../decorators/roles.decorator';
|
||||
import { type GenerateKycUploadUrlsDto } from '../dto/generate-kyc-upload-urls.dto';
|
||||
import { LoginDto } from '../dto/login.dto';
|
||||
import { type RefreshTokenDto } from '../dto/refresh-token.dto';
|
||||
import { type RegisterDto } from '../dto/register.dto';
|
||||
import { type SubmitKycDto } from '../dto/submit-kyc.dto';
|
||||
import { type UpdateProfileDto } from '../dto/update-profile.dto';
|
||||
import { type VerifyEmailChangeDto } from '../dto/verify-email-change.dto';
|
||||
import { type VerifyKycDto } from '../dto/verify-kyc.dto';
|
||||
import { type VerifyPhoneChangeDto } from '../dto/verify-phone-change.dto';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||
import { LocalAuthGuard } from '../guards/local-auth.guard';
|
||||
import { RolesGuard } from '../guards/roles.guard';
|
||||
@@ -227,11 +231,29 @@ export class AuthController {
|
||||
@Body() dto: UpdateProfileDto,
|
||||
): Promise<{ message: string; data: UpdateProfileResultDto }> {
|
||||
const result: UpdateProfileResultDto = await this.commandBus.execute(
|
||||
new UpdateProfileCommand(user.sub, dto.fullName, dto.avatarUrl, dto.email),
|
||||
new UpdateProfileCommand(user.sub, dto.fullName, dto.avatarUrl, dto.email, dto.phoneNumber),
|
||||
);
|
||||
return { message: 'Cập nhật hồ sơ thành công', data: result };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('profile/verify-phone')
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Verify phone number change with SMS OTP code' })
|
||||
@ApiResponse({ status: 201, description: 'Phone number changed successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid or expired OTP code' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 409, description: 'Phone number already in use' })
|
||||
async verifyPhoneChange(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: VerifyPhoneChangeDto,
|
||||
): Promise<{ message: string; data: VerifyPhoneChangeResultDto }> {
|
||||
const result: VerifyPhoneChangeResultDto = await this.commandBus.execute(
|
||||
new VerifyPhoneChangeCommand(user.sub, dto.code),
|
||||
);
|
||||
return { message: 'Số điện thoại đã được cập nhật thành công', data: result };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('profile/verify-email')
|
||||
@ApiBearerAuth('JWT')
|
||||
@@ -268,7 +290,7 @@ export class AuthController {
|
||||
@ApiResponse({ status: 400, description: 'Validation error' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
async generateKycUploadUrls(
|
||||
@Body() body: { files: KycFileRequest[] },
|
||||
@Body() body: GenerateKycUploadUrlsDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<{ field: string; uploadUrl: string; publicUrl: string; objectKey: string }[]> {
|
||||
return this.commandBus.execute(
|
||||
@@ -284,20 +306,9 @@ export class AuthController {
|
||||
@ApiResponse({ status: 400, description: 'Validation error' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
async submitKyc(
|
||||
@Body()
|
||||
body: {
|
||||
documentType: string;
|
||||
documentNumber: string;
|
||||
frontImageUrl: string;
|
||||
backImageUrl?: string;
|
||||
selfieUrl?: string;
|
||||
},
|
||||
@Body() body: SubmitKycDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<{ message: string }> {
|
||||
if (!body.frontImageUrl) {
|
||||
throw new ValidationException('Vui lòng tải ảnh mặt trước giấy tờ');
|
||||
}
|
||||
|
||||
return this.commandBus.execute(
|
||||
new SubmitKycCommand(
|
||||
user.sub,
|
||||
|
||||
@@ -2,4 +2,5 @@ export { RegisterDto } from './register.dto';
|
||||
export { LoginDto } from './login.dto';
|
||||
export { RefreshTokenDto } from './refresh-token.dto';
|
||||
export { VerifyKycDto } from './verify-kyc.dto';
|
||||
export { GenerateKycUploadUrlsDto, KycFileRequestDto } from './generate-kyc-upload-urls.dto';
|
||||
export { VerifyMfaSetupDto, VerifyMfaChallengeDto, UseBackupCodeDto, DisableMfaDto } from './mfa.dto';
|
||||
|
||||
@@ -21,4 +21,13 @@ export class UpdateProfileDto {
|
||||
@IsOptional()
|
||||
@IsEmail({}, { message: 'Email không hợp lệ' })
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: '0912345678',
|
||||
description: 'Vietnamese phone number (will trigger SMS OTP re-verification)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(9, { message: 'Số điện thoại không hợp lệ' })
|
||||
phoneNumber?: string;
|
||||
}
|
||||
|
||||
@@ -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 { CqrsModule } from '@nestjs/cqrs';
|
||||
import { SearchModule } from '@modules/search';
|
||||
import { CreateIndustrialListingHandler } from './application/commands/create-industrial-listing/create-industrial-listing.handler';
|
||||
import { CreateIndustrialParkHandler } from './application/commands/create-industrial-park/create-industrial-park.handler';
|
||||
import { DeleteIndustrialListingHandler } from './application/commands/delete-industrial-listing/delete-industrial-listing.handler';
|
||||
import { UpdateIndustrialListingHandler } from './application/commands/update-industrial-listing/update-industrial-listing.handler';
|
||||
import { UpdateIndustrialParkHandler } from './application/commands/update-industrial-park/update-industrial-park.handler';
|
||||
import { AnalyzeIndustrialLocationHandler } from './application/queries/analyze-industrial-location/analyze-industrial-location.handler';
|
||||
import { CompareIndustrialParksHandler } from './application/queries/compare-industrial-parks/compare-industrial-parks.handler';
|
||||
import { EstimateIndustrialRentHandler } from './application/queries/estimate-industrial-rent/estimate-industrial-rent.handler';
|
||||
import { GetIndustrialListingHandler } from './application/queries/get-industrial-listing/get-industrial-listing.handler';
|
||||
import { GetIndustrialParkHandler } from './application/queries/get-industrial-park/get-industrial-park.handler';
|
||||
import { IndustrialMarketHandler } from './application/queries/industrial-market/industrial-market.handler';
|
||||
import { IndustrialParkStatsHandler } from './application/queries/industrial-park-stats/industrial-park-stats.handler';
|
||||
import { ListIndustrialListingsHandler } from './application/queries/list-industrial-listings/list-industrial-listings.handler';
|
||||
import { ListIndustrialParksHandler } from './application/queries/list-industrial-parks/list-industrial-parks.handler';
|
||||
import { INDUSTRIAL_LISTING_REPOSITORY } from './domain/repositories/industrial-listing.repository';
|
||||
import { INDUSTRIAL_PARK_REPOSITORY } from './domain/repositories/industrial-park.repository';
|
||||
import { PrismaIndustrialListingRepository } from './infrastructure/repositories/prisma-industrial-listing.repository';
|
||||
import { PrismaIndustrialParkRepository } from './infrastructure/repositories/prisma-industrial-park.repository';
|
||||
import { TypesenseIndustrialService } from './infrastructure/services/typesense-industrial.service';
|
||||
import { IndustrialListingsController } from './presentation/controllers/industrial-listings.controller';
|
||||
import { IndustrialParksController } from './presentation/controllers/industrial-parks.controller';
|
||||
|
||||
const CommandHandlers = [
|
||||
CreateIndustrialParkHandler,
|
||||
UpdateIndustrialParkHandler,
|
||||
CreateIndustrialListingHandler,
|
||||
UpdateIndustrialListingHandler,
|
||||
DeleteIndustrialListingHandler,
|
||||
];
|
||||
|
||||
const QueryHandlers = [
|
||||
AnalyzeIndustrialLocationHandler,
|
||||
EstimateIndustrialRentHandler,
|
||||
GetIndustrialParkHandler,
|
||||
ListIndustrialParksHandler,
|
||||
CompareIndustrialParksHandler,
|
||||
IndustrialParkStatsHandler,
|
||||
IndustrialMarketHandler,
|
||||
GetIndustrialListingHandler,
|
||||
ListIndustrialListingsHandler,
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [CqrsModule, SearchModule],
|
||||
controllers: [IndustrialParksController],
|
||||
controllers: [IndustrialParksController, IndustrialListingsController],
|
||||
providers: [
|
||||
{ provide: INDUSTRIAL_PARK_REPOSITORY, useClass: PrismaIndustrialParkRepository },
|
||||
{ provide: INDUSTRIAL_LISTING_REPOSITORY, useClass: PrismaIndustrialListingRepository },
|
||||
TypesenseIndustrialService,
|
||||
...CommandHandlers,
|
||||
...QueryHandlers,
|
||||
],
|
||||
exports: [INDUSTRIAL_PARK_REPOSITORY, TypesenseIndustrialService],
|
||||
exports: [INDUSTRIAL_PARK_REPOSITORY, INDUSTRIAL_LISTING_REPOSITORY, TypesenseIndustrialService],
|
||||
})
|
||||
export class IndustrialModule {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
interface RawIndustrialListing {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
parkId: string;
|
||||
parkName: string;
|
||||
propertyType: string;
|
||||
leaseType: string;
|
||||
province: string;
|
||||
region: string;
|
||||
areaM2: number;
|
||||
priceUsdM2: number | null;
|
||||
ceilingHeightM: number | null;
|
||||
floorLoadTonM2: number | null;
|
||||
targetIndustries: string[] | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
occupancyRate: number;
|
||||
status: string;
|
||||
publishedAt: Date | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TypesenseIndustrialService implements OnModuleInit {
|
||||
private client: TypesenseClient | null = null;
|
||||
@@ -103,6 +125,7 @@ export class TypesenseIndustrialService implements OnModuleInit {
|
||||
this.client = this.typesenseClient.getClient();
|
||||
await this.ensureCollections();
|
||||
await this.syncParks();
|
||||
await this.syncListings();
|
||||
} catch (err) {
|
||||
this.logger.warn(`Typesense industrial init failed (non-fatal): ${err}`, 'TypesenseIndustrial');
|
||||
}
|
||||
@@ -172,6 +195,105 @@ export class TypesenseIndustrialService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
async syncListings(): Promise<void> {
|
||||
if (!this.client) return;
|
||||
|
||||
const listings = await this.prisma.$queryRaw<RawIndustrialListing[]>`
|
||||
SELECT l.id, l.title, l.description, l."parkId", p.name as "parkName",
|
||||
l."propertyType"::text, l."leaseType"::text, p.province, p.region::text,
|
||||
l."areaM2", l."priceUsdM2", l."ceilingHeightM", l."floorLoadTonM2",
|
||||
p."targetIndustries",
|
||||
ST_Y(p.location::geometry) as lat, ST_X(p.location::geometry) as lng,
|
||||
p."occupancyRate", l.status::text, l."publishedAt"
|
||||
FROM "IndustrialListing" l
|
||||
JOIN "IndustrialPark" p ON p.id = l."parkId"
|
||||
WHERE l.status != 'EXPIRED'
|
||||
`;
|
||||
|
||||
if (listings.length === 0) return;
|
||||
|
||||
const docs = listings.map((l) => ({
|
||||
id: l.id,
|
||||
listingId: l.id,
|
||||
title: l.title,
|
||||
description: l.description ?? undefined,
|
||||
parkName: l.parkName,
|
||||
parkId: l.parkId,
|
||||
propertyType: l.propertyType.toLowerCase(),
|
||||
leaseType: l.leaseType.toLowerCase(),
|
||||
province: l.province,
|
||||
region: l.region.toLowerCase(),
|
||||
areaM2: l.areaM2,
|
||||
priceUsdM2: l.priceUsdM2 ?? undefined,
|
||||
ceilingHeightM: l.ceilingHeightM ?? undefined,
|
||||
floorLoadTonM2: l.floorLoadTonM2 ?? undefined,
|
||||
targetIndustries: l.targetIndustries ?? [],
|
||||
location: [Number(l.lat), Number(l.lng)],
|
||||
occupancyRate: l.occupancyRate,
|
||||
status: l.status.toLowerCase(),
|
||||
publishedAt: l.publishedAt ? Math.floor(l.publishedAt.getTime() / 1000) : undefined,
|
||||
}));
|
||||
|
||||
try {
|
||||
const jsonl = docs.map((d) => JSON.stringify(d)).join('\n');
|
||||
await this.client.collections(INDUSTRIAL_LISTINGS_COLLECTION).documents().import(jsonl, { action: 'upsert' });
|
||||
this.logger.log(`Synced ${docs.length} listings to Typesense`, 'TypesenseIndustrial');
|
||||
} catch (err) {
|
||||
this.logger.warn(`Listing sync error: ${err}`, 'TypesenseIndustrial');
|
||||
}
|
||||
}
|
||||
|
||||
async indexListing(listingId: string): Promise<void> {
|
||||
if (!this.client) return;
|
||||
|
||||
const [listing] = await this.prisma.$queryRaw<RawIndustrialListing[]>`
|
||||
SELECT l.id, l.title, l.description, l."parkId", p.name as "parkName",
|
||||
l."propertyType"::text, l."leaseType"::text, p.province, p.region::text,
|
||||
l."areaM2", l."priceUsdM2", l."ceilingHeightM", l."floorLoadTonM2",
|
||||
p."targetIndustries",
|
||||
ST_Y(p.location::geometry) as lat, ST_X(p.location::geometry) as lng,
|
||||
p."occupancyRate", l.status::text, l."publishedAt"
|
||||
FROM "IndustrialListing" l
|
||||
JOIN "IndustrialPark" p ON p.id = l."parkId"
|
||||
WHERE l.id = ${listingId}
|
||||
`;
|
||||
|
||||
if (!listing) return;
|
||||
|
||||
const doc = {
|
||||
id: listing.id,
|
||||
listingId: listing.id,
|
||||
title: listing.title,
|
||||
description: listing.description ?? undefined,
|
||||
parkName: listing.parkName,
|
||||
parkId: listing.parkId,
|
||||
propertyType: listing.propertyType.toLowerCase(),
|
||||
leaseType: listing.leaseType.toLowerCase(),
|
||||
province: listing.province,
|
||||
region: listing.region.toLowerCase(),
|
||||
areaM2: listing.areaM2,
|
||||
priceUsdM2: listing.priceUsdM2 ?? undefined,
|
||||
ceilingHeightM: listing.ceilingHeightM ?? undefined,
|
||||
floorLoadTonM2: listing.floorLoadTonM2 ?? undefined,
|
||||
targetIndustries: listing.targetIndustries ?? [],
|
||||
location: [Number(listing.lat), Number(listing.lng)],
|
||||
occupancyRate: listing.occupancyRate,
|
||||
status: listing.status.toLowerCase(),
|
||||
publishedAt: listing.publishedAt ? Math.floor(listing.publishedAt.getTime() / 1000) : undefined,
|
||||
};
|
||||
|
||||
await this.client.collections(INDUSTRIAL_LISTINGS_COLLECTION).documents().upsert(doc);
|
||||
}
|
||||
|
||||
async deleteListing(listingId: string): Promise<void> {
|
||||
if (!this.client) return;
|
||||
try {
|
||||
await this.client.collections(INDUSTRIAL_LISTINGS_COLLECTION).documents(listingId).delete();
|
||||
} catch {
|
||||
// Document may not exist in Typesense
|
||||
}
|
||||
}
|
||||
|
||||
async indexPark(parkId: string): Promise<void> {
|
||||
if (!this.client) return;
|
||||
|
||||
|
||||
@@ -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 { JwtAuthGuard, Roles, RolesGuard } from '@modules/auth';
|
||||
import { NotFoundException } from '@modules/shared';
|
||||
import { AnalyzeIndustrialLocationQuery } from '../../application/queries/analyze-industrial-location/analyze-industrial-location.query';
|
||||
import { CreateIndustrialParkCommand } from '../../application/commands/create-industrial-park/create-industrial-park.command';
|
||||
import { EstimateIndustrialRentQuery } from '../../application/queries/estimate-industrial-rent/estimate-industrial-rent.query';
|
||||
import { UpdateIndustrialParkCommand } from '../../application/commands/update-industrial-park/update-industrial-park.command';
|
||||
import { CompareIndustrialParksQuery } from '../../application/queries/compare-industrial-parks/compare-industrial-parks.query';
|
||||
import { GetIndustrialParkQuery } from '../../application/queries/get-industrial-park/get-industrial-park.query';
|
||||
import { IndustrialMarketQuery } from '../../application/queries/industrial-market/industrial-market.query';
|
||||
import { IndustrialParkStatsQuery } from '../../application/queries/industrial-park-stats/industrial-park-stats.query';
|
||||
import { ListIndustrialParksQuery } from '../../application/queries/list-industrial-parks/list-industrial-parks.query';
|
||||
import { type AnalyzeIndustrialLocationDto } from '../dto/analyze-industrial-location.dto';
|
||||
import { type CompareIndustrialParksDto } from '../dto/compare-industrial-parks.dto';
|
||||
import { type CreateIndustrialParkDto } from '../dto/create-industrial-park.dto';
|
||||
import { type EstimateIndustrialRentDto } from '../dto/estimate-industrial-rent.dto';
|
||||
import { type SearchIndustrialParksDto } from '../dto/search-industrial-parks.dto';
|
||||
import { type UpdateIndustrialParkDto } from '../dto/update-industrial-park.dto';
|
||||
|
||||
@@ -78,6 +82,38 @@ export class IndustrialParksController {
|
||||
return this.queryBus.execute(new IndustrialMarketQuery());
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Phân tích vị trí KCN', description: 'Đánh giá vị trí dựa trên hạ tầng, kết nối, lao động' })
|
||||
@ApiResponse({ status: 200, description: 'Kết quả phân tích vị trí' })
|
||||
@Post('analyze-location')
|
||||
async analyzeLocation(@Body() dto: AnalyzeIndustrialLocationDto) {
|
||||
return this.queryBus.execute(
|
||||
new AnalyzeIndustrialLocationQuery(
|
||||
dto.latitude,
|
||||
dto.longitude,
|
||||
dto.park_name ?? null,
|
||||
dto.target_industry ?? null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Ước tính giá thuê KCN', description: 'Tính giá thuê BĐS công nghiệp theo tỉnh, loại, diện tích' })
|
||||
@ApiResponse({ status: 200, description: 'Kết quả ước tính giá thuê' })
|
||||
@Post('estimate-rent')
|
||||
async estimateRent(@Body() dto: EstimateIndustrialRentDto) {
|
||||
return this.queryBus.execute(
|
||||
new EstimateIndustrialRentQuery(
|
||||
dto.province,
|
||||
dto.property_type,
|
||||
dto.area_m2,
|
||||
dto.lease_duration_years,
|
||||
dto.park_name ?? null,
|
||||
dto.requires_crane ?? false,
|
||||
dto.required_power_kva ?? null,
|
||||
dto.requires_wastewater ?? false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Admin endpoints ───────────────────────────────────────────────
|
||||
|
||||
@ApiOperation({ summary: 'Tạo KCN (admin)', description: 'Tạo mới khu công nghiệp' })
|
||||
|
||||
@@ -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,
|
||||
oldPrice: event.oldPrice,
|
||||
newPrice: event.newPrice,
|
||||
source: event.source,
|
||||
changedAt: event.occurredAt,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface PriceHistoryItem {
|
||||
id: string;
|
||||
oldPrice: bigint;
|
||||
newPrice: bigint;
|
||||
source: string;
|
||||
changedAt: Date;
|
||||
}
|
||||
|
||||
@@ -21,6 +22,7 @@ export class GetPriceHistoryHandler implements IQueryHandler<GetPriceHistoryQuer
|
||||
id: true,
|
||||
oldPrice: true,
|
||||
newPrice: true,
|
||||
source: true,
|
||||
changedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ListingApprovedEvent } from '../events/listing-approved.event';
|
||||
import { ListingCreatedEvent } from '../events/listing-created.event';
|
||||
import { ListingPriceChangedEvent } from '../events/listing-price-changed.event';
|
||||
import { ListingSoldEvent } from '../events/listing-sold.event';
|
||||
import { ListingStatusChangedEvent } from '../events/listing-status-changed.event';
|
||||
|
||||
@@ -51,6 +52,34 @@ describe('Listings Domain Events', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListingPriceChangedEvent', () => {
|
||||
it('creates event with correct properties', () => {
|
||||
const event = new ListingPriceChangedEvent('listing-1', 5_000_000_000n, 6_000_000_000n, 'manual_update');
|
||||
|
||||
expect(event.eventName).toBe('listing.price_changed');
|
||||
expect(event.aggregateId).toBe('listing-1');
|
||||
expect(event.oldPrice).toBe(5_000_000_000n);
|
||||
expect(event.newPrice).toBe(6_000_000_000n);
|
||||
expect(event.source).toBe('manual_update');
|
||||
expect(event.occurredAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('defaults source to manual_update', () => {
|
||||
const event = new ListingPriceChangedEvent('listing-2', 1_000_000n, 2_000_000n);
|
||||
expect(event.source).toBe('manual_update');
|
||||
});
|
||||
|
||||
it('accepts admin_override source', () => {
|
||||
const event = new ListingPriceChangedEvent('listing-3', 1n, 2n, 'admin_override');
|
||||
expect(event.source).toBe('admin_override');
|
||||
});
|
||||
|
||||
it('accepts market_adjustment source', () => {
|
||||
const event = new ListingPriceChangedEvent('listing-4', 1n, 2n, 'market_adjustment');
|
||||
expect(event.source).toBe('market_adjustment');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListingStatusChangedEvent', () => {
|
||||
it('creates event with correct properties', () => {
|
||||
const event = new ListingStatusChangedEvent('listing-1', 'prop-1', 'DRAFT', 'PENDING_REVIEW');
|
||||
|
||||
@@ -142,6 +142,33 @@ describe('ListingEntity', () => {
|
||||
const fields = listing.updateContent({});
|
||||
expect(fields).toEqual([]);
|
||||
});
|
||||
|
||||
it('should emit ListingPriceChangedEvent when price actually changes', () => {
|
||||
const listing = makeDefaultListing();
|
||||
listing.clearDomainEvents();
|
||||
|
||||
listing.updateContent({ priceVND: 6_000_000_000n, areaM2: 100 });
|
||||
|
||||
const events = listing.domainEvents;
|
||||
const priceEvent = events.find((e) => e.eventName === 'listing.price_changed');
|
||||
expect(priceEvent).toBeDefined();
|
||||
expect((priceEvent as { oldPrice: bigint; newPrice: bigint }).oldPrice).toBe(
|
||||
5_000_000_000n,
|
||||
);
|
||||
expect((priceEvent as { oldPrice: bigint; newPrice: bigint }).newPrice).toBe(
|
||||
6_000_000_000n,
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT emit ListingPriceChangedEvent when price stays the same', () => {
|
||||
const listing = makeDefaultListing();
|
||||
listing.clearDomainEvents();
|
||||
|
||||
listing.updateContent({ priceVND: 5_000_000_000n, areaM2: 100 });
|
||||
|
||||
const events = listing.domainEvents;
|
||||
expect(events.some((e) => e.eventName === 'listing.price_changed')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markEditedForReModeration', () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { DomainEvent } from '@modules/shared';
|
||||
|
||||
export type PriceChangeSource = 'manual_update' | 'admin_override' | 'market_adjustment';
|
||||
|
||||
export class ListingPriceChangedEvent implements DomainEvent {
|
||||
readonly eventName = 'listing.price_changed';
|
||||
readonly occurredAt = new Date();
|
||||
@@ -8,5 +10,6 @@ export class ListingPriceChangedEvent implements DomainEvent {
|
||||
public readonly aggregateId: string,
|
||||
public readonly oldPrice: bigint,
|
||||
public readonly newPrice: bigint,
|
||||
public readonly source: PriceChangeSource = 'manual_update',
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,19 @@ export { ListingsModule } from './listings.module';
|
||||
export { ListingEntity, type ListingProps } from './domain/entities/listing.entity';
|
||||
export { ListingCreatedEvent } from './domain/events/listing-created.event';
|
||||
export { ModerateListingCommand } from './application/commands/moderate-listing/moderate-listing.command';
|
||||
export {
|
||||
AdminFeatureListingCommand,
|
||||
type AdminFeatureAction,
|
||||
} from './application/commands/admin-feature-listing/admin-feature-listing.command';
|
||||
export { type AdminFeatureListingResult } from './application/commands/admin-feature-listing/admin-feature-listing.handler';
|
||||
export {
|
||||
PromoteFeaturedListingCommand,
|
||||
type PromoteFeaturedDuration,
|
||||
} from './application/commands/promote-featured-listing/promote-featured-listing.command';
|
||||
export {
|
||||
type PromoteFeaturedListingResult,
|
||||
FEATURED_LISTINGS_PROMOTED_METRIC,
|
||||
} from './application/commands/promote-featured-listing/promote-featured-listing.handler';
|
||||
export { LISTING_REPOSITORY, IListingRepository, type ListingSearchParams, type PaginatedResult } from './domain/repositories/listing.repository';
|
||||
export { ListingPriceChangedEvent } from './domain/events/listing-price-changed.event';
|
||||
export { ListingSoldEvent } from './domain/events/listing-sold.event';
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
import { AdminFeatureListingHandler } from './application/commands/admin-feature-listing/admin-feature-listing.handler';
|
||||
import { CreateListingHandler } from './application/commands/create-listing/create-listing.handler';
|
||||
import { FeatureListingHandler } from './application/commands/feature-listing/feature-listing.handler';
|
||||
import { ModerateListingHandler } from './application/commands/moderate-listing/moderate-listing.handler';
|
||||
import { PromoteFeaturedListingHandler } from './application/commands/promote-featured-listing/promote-featured-listing.handler';
|
||||
import { UpdateListingHandler } from './application/commands/update-listing/update-listing.handler';
|
||||
import { UpdateListingStatusHandler } from './application/commands/update-listing-status/update-listing-status.handler';
|
||||
import { UploadMediaHandler } from './application/commands/upload-media/upload-media.handler';
|
||||
@@ -28,6 +30,8 @@ import { ListingsController } from './presentation/controllers/listings.controll
|
||||
const CommandHandlers = [
|
||||
CreateListingHandler,
|
||||
FeatureListingHandler,
|
||||
PromoteFeaturedListingHandler,
|
||||
AdminFeatureListingHandler,
|
||||
UpdateListingHandler,
|
||||
UpdateListingStatusHandler,
|
||||
UploadMediaHandler,
|
||||
|
||||
@@ -96,6 +96,78 @@ describe('ListingsController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPriceHistory', () => {
|
||||
it('should execute GetPriceHistoryQuery via query bus', async () => {
|
||||
const mockHistory = [
|
||||
{ id: 'ph-1', oldPrice: '5000000000', newPrice: '6000000000', source: 'manual_update', changedAt: '2026-04-16T00:00:00.000Z' },
|
||||
];
|
||||
mockQueryBus.execute.mockResolvedValue(mockHistory);
|
||||
|
||||
const result = await controller.getPriceHistory('listing-1');
|
||||
|
||||
expect(result).toEqual(mockHistory);
|
||||
expect(mockQueryBus.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return empty array when no price history exists', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue([]);
|
||||
|
||||
const result = await controller.getPriceHistory('listing-no-history');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockQueryBus.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateListing', () => {
|
||||
it('should execute UpdateListingCommand via command bus', async () => {
|
||||
const mockResult = {
|
||||
listingId: 'listing-1',
|
||||
status: 'DRAFT',
|
||||
updatedFields: ['title', 'priceVND'],
|
||||
resubmittedForModeration: false,
|
||||
};
|
||||
mockCommandBus.execute.mockResolvedValue(mockResult);
|
||||
|
||||
const dto = {
|
||||
title: 'Căn hộ 3PN view sông mới',
|
||||
priceVND: 6_000_000_000n,
|
||||
};
|
||||
const user = { sub: 'seller-1', email: 'seller@example.com', role: 'SELLER' };
|
||||
|
||||
const result = await controller.updateListing('listing-1', dto as any, user as any);
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should pass all optional fields to the command', async () => {
|
||||
const mockResult = {
|
||||
listingId: 'listing-1',
|
||||
status: 'PENDING_REVIEW',
|
||||
updatedFields: ['title', 'description', 'priceVND', 'rentPriceMonthly', 'amenities', 'mediaOrder'],
|
||||
resubmittedForModeration: true,
|
||||
};
|
||||
mockCommandBus.execute.mockResolvedValue(mockResult);
|
||||
|
||||
const dto = {
|
||||
title: 'Tiêu đề cập nhật',
|
||||
description: 'Mô tả chi tiết hơn cho căn hộ',
|
||||
priceVND: 5_500_000_000n,
|
||||
rentPriceMonthly: 25_000_000n,
|
||||
amenities: ['Hồ bơi', 'Gym'],
|
||||
mediaOrder: [{ mediaId: 'media-1', order: 0 }],
|
||||
};
|
||||
const user = { sub: 'seller-1', email: 'seller@example.com', role: 'SELLER' };
|
||||
|
||||
const result = await controller.updateListing('listing-1', dto as any, user as any);
|
||||
|
||||
expect(result.resubmittedForModeration).toBe(true);
|
||||
expect(result.status).toBe('PENDING_REVIEW');
|
||||
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStatus', () => {
|
||||
it('should execute UpdateListingStatusCommand via command bus', async () => {
|
||||
mockCommandBus.execute.mockResolvedValue({ status: 'ACTIVE' });
|
||||
|
||||
@@ -33,6 +33,8 @@ import type { CreateListingResult } from '../../application/commands/create-list
|
||||
import { FeatureListingCommand } from '../../application/commands/feature-listing/feature-listing.command';
|
||||
import type { FeatureListingResult } from '../../application/commands/feature-listing/feature-listing.handler';
|
||||
import { ModerateListingCommand } from '../../application/commands/moderate-listing/moderate-listing.command';
|
||||
import { PromoteFeaturedListingCommand } from '../../application/commands/promote-featured-listing/promote-featured-listing.command';
|
||||
import type { PromoteFeaturedListingResult } from '../../application/commands/promote-featured-listing/promote-featured-listing.handler';
|
||||
import { UpdateListingCommand } from '../../application/commands/update-listing/update-listing.command';
|
||||
import type { UpdateListingResult } from '../../application/commands/update-listing/update-listing.handler';
|
||||
import { UpdateListingStatusCommand } from '../../application/commands/update-listing-status/update-listing-status.command';
|
||||
@@ -47,6 +49,7 @@ import type { PaginatedResult } from '../../domain/repositories/listing.reposito
|
||||
import type { CreateListingDto } from '../dto/create-listing.dto';
|
||||
import type { FeatureListingDto } from '../dto/feature-listing.dto';
|
||||
import type { ModerateListingDto } from '../dto/moderate-listing.dto';
|
||||
import type { PromoteFeaturedListingDto } from '../dto/promote-featured-listing.dto';
|
||||
import { type SearchListingsDto } from '../dto/search-listings.dto';
|
||||
import type { UpdateListingStatusDto } from '../dto/update-listing-status.dto';
|
||||
import type { UpdateListingDto } from '../dto/update-listing.dto';
|
||||
@@ -319,4 +322,28 @@ export class ListingsController {
|
||||
new FeatureListingCommand(id, user.sub, dto.package, dto.provider, dto.returnUrl, ip),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({
|
||||
summary: 'Promote a listing via subscription entitlement (no payment)',
|
||||
description:
|
||||
'Sử dụng quota `featured_listings_promoted` của subscription để bật featured không qua thanh toán.',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'Listing UUID' })
|
||||
@ApiResponse({ status: 201, description: 'Listing promoted successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid duration or listing not ACTIVE' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 403, description: 'Not owner/agent or quota exhausted' })
|
||||
@UseGuards(JwtAuthGuard, QuotaGuard)
|
||||
@RequireQuota('featured_listings_promoted')
|
||||
@Post(':id/promote')
|
||||
async promoteListing(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: PromoteFeaturedListingDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<PromoteFeaturedListingResult> {
|
||||
return this.commandBus.execute(
|
||||
new PromoteFeaturedListingCommand(id, user.sub, dto.durationDays),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
McpCoreModule.forRoot({
|
||||
aiServiceBaseUrl: process.env['AI_SERVICE_URL'] || 'http://localhost:8000',
|
||||
apiBaseUrl: process.env['API_BASE_URL'] || 'http://localhost:3001/api/v1',
|
||||
typesenseCollectionName: 'listings',
|
||||
skipDefaultController: true,
|
||||
}),
|
||||
|
||||
@@ -9,6 +9,11 @@ describe('MetricsService', () => {
|
||||
let mockSearchQueriesCounter: { inc: ReturnType<typeof vi.fn> };
|
||||
let mockRequestDurationHistogram: { observe: ReturnType<typeof vi.fn> };
|
||||
let mockHttpRequestsCounter: { inc: ReturnType<typeof vi.fn> };
|
||||
let mockWsConnectedClientsGauge: {
|
||||
inc: ReturnType<typeof vi.fn>;
|
||||
set: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockWsMessagesCounter: { inc: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockListingsCreatedCounter = { inc: vi.fn() };
|
||||
@@ -17,6 +22,8 @@ describe('MetricsService', () => {
|
||||
mockSearchQueriesCounter = { inc: vi.fn() };
|
||||
mockRequestDurationHistogram = { observe: vi.fn() };
|
||||
mockHttpRequestsCounter = { inc: vi.fn() };
|
||||
mockWsConnectedClientsGauge = { inc: vi.fn(), set: vi.fn() };
|
||||
mockWsMessagesCounter = { inc: vi.fn() };
|
||||
|
||||
service = new MetricsService(
|
||||
mockListingsCreatedCounter as unknown as Counter,
|
||||
@@ -25,6 +32,8 @@ describe('MetricsService', () => {
|
||||
mockSearchQueriesCounter as unknown as Counter,
|
||||
mockRequestDurationHistogram as unknown as Histogram,
|
||||
mockHttpRequestsCounter as unknown as Counter,
|
||||
mockWsConnectedClientsGauge as unknown as Gauge,
|
||||
mockWsMessagesCounter as unknown as Counter,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -102,4 +111,41 @@ describe('MetricsService', () => {
|
||||
expect.objectContaining({ status_code: '503' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('recordWsConnection increments the connected-clients gauge with +1 on connect', () => {
|
||||
service.recordWsConnection('/notifications', 1);
|
||||
|
||||
expect(mockWsConnectedClientsGauge.inc).toHaveBeenCalledWith(
|
||||
{ namespace: '/notifications' },
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('recordWsConnection decrements the connected-clients gauge with -1 on disconnect', () => {
|
||||
service.recordWsConnection('/notifications', -1);
|
||||
|
||||
expect(mockWsConnectedClientsGauge.inc).toHaveBeenCalledWith(
|
||||
{ namespace: '/notifications' },
|
||||
-1,
|
||||
);
|
||||
});
|
||||
|
||||
it('setWsConnectedClients sets the gauge for a namespace', () => {
|
||||
service.setWsConnectedClients('/notifications', 0);
|
||||
|
||||
expect(mockWsConnectedClientsGauge.set).toHaveBeenCalledWith(
|
||||
{ namespace: '/notifications' },
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('recordWsMessage increments the messages counter with namespace/event/direction', () => {
|
||||
service.recordWsMessage('/notifications', 'notification:new', 'out');
|
||||
|
||||
expect(mockWsMessagesCounter.inc).toHaveBeenCalledWith({
|
||||
namespace: '/notifications',
|
||||
event: 'notification:new',
|
||||
direction: 'out',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
GOODGO_SEARCH_QUERIES_TOTAL,
|
||||
GOODGO_API_REQUEST_DURATION,
|
||||
HTTP_REQUESTS_TOTAL,
|
||||
GOODGO_WS_CONNECTED_CLIENTS,
|
||||
GOODGO_WS_MESSAGES_TOTAL,
|
||||
WEB_VITALS_LCP,
|
||||
WEB_VITALS_FCP,
|
||||
WEB_VITALS_CLS,
|
||||
@@ -31,6 +33,10 @@ export class MetricsService {
|
||||
private readonly requestDurationHistogram: Histogram,
|
||||
@InjectMetric(HTTP_REQUESTS_TOTAL)
|
||||
private readonly httpRequestsCounter: Counter,
|
||||
@InjectMetric(GOODGO_WS_CONNECTED_CLIENTS)
|
||||
private readonly wsConnectedClientsGauge: Gauge,
|
||||
@InjectMetric(GOODGO_WS_MESSAGES_TOTAL)
|
||||
private readonly wsMessagesCounter: Counter,
|
||||
@InjectMetric(WEB_VITALS_LCP)
|
||||
private readonly lcpHistogram: Histogram,
|
||||
@InjectMetric(WEB_VITALS_FCP)
|
||||
@@ -81,6 +87,25 @@ export class MetricsService {
|
||||
this.httpRequestsCounter.inc(labels);
|
||||
}
|
||||
|
||||
/** Track a WebSocket client connection (++) or disconnection (--). */
|
||||
recordWsConnection(namespace: string, delta: 1 | -1): void {
|
||||
this.wsConnectedClientsGauge.inc({ namespace }, delta);
|
||||
}
|
||||
|
||||
/** Reset the connected-clients gauge for a namespace (e.g. on shutdown). */
|
||||
setWsConnectedClients(namespace: string, count: number): void {
|
||||
this.wsConnectedClientsGauge.set({ namespace }, count);
|
||||
}
|
||||
|
||||
/** Record a WebSocket message emitted/received on a given event. */
|
||||
recordWsMessage(
|
||||
namespace: string,
|
||||
event: string,
|
||||
direction: 'in' | 'out',
|
||||
): void {
|
||||
this.wsMessagesCounter.inc({ namespace, event, direction });
|
||||
}
|
||||
|
||||
/** Map metric name → the correct histogram. */
|
||||
private readonly vitalHistograms: Record<string, Histogram | undefined> = {};
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ export const DB_QUERY_DURATION = 'db_query_duration_seconds';
|
||||
export const DB_POOL_ACTIVE_CONNECTIONS = 'db_pool_active_connections';
|
||||
export const SEARCH_QUERY_DURATION = 'search_query_duration_seconds';
|
||||
|
||||
// ── WebSocket Metrics ──
|
||||
export const GOODGO_WS_CONNECTED_CLIENTS = 'goodgo_ws_connected_clients';
|
||||
export const GOODGO_WS_MESSAGES_TOTAL = 'goodgo_ws_messages_total';
|
||||
|
||||
// ── Web Vitals / RUM Metrics ──
|
||||
export const WEB_VITALS_LCP = 'goodgo_web_vitals_lcp_seconds';
|
||||
export const WEB_VITALS_FCP = 'goodgo_web_vitals_fcp_seconds';
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
DB_QUERY_DURATION,
|
||||
DB_POOL_ACTIVE_CONNECTIONS,
|
||||
SEARCH_QUERY_DURATION,
|
||||
GOODGO_WS_CONNECTED_CLIENTS,
|
||||
GOODGO_WS_MESSAGES_TOTAL,
|
||||
WEB_VITALS_LCP,
|
||||
WEB_VITALS_FCP,
|
||||
WEB_VITALS_CLS,
|
||||
@@ -83,6 +85,18 @@ import { HttpMetricsInterceptor } from './presentation/interceptors/http-metrics
|
||||
labelNames: ['plan'],
|
||||
}),
|
||||
|
||||
// ── WebSocket Metrics ──
|
||||
makeGaugeProvider({
|
||||
name: GOODGO_WS_CONNECTED_CLIENTS,
|
||||
help: 'Number of active WebSocket clients',
|
||||
labelNames: ['namespace'],
|
||||
}),
|
||||
makeCounterProvider({
|
||||
name: GOODGO_WS_MESSAGES_TOTAL,
|
||||
help: 'Total number of WebSocket messages emitted/received',
|
||||
labelNames: ['namespace', 'event', 'direction'],
|
||||
}),
|
||||
|
||||
// ── Services & Interceptors ──
|
||||
MetricsService,
|
||||
HttpMetricsInterceptor,
|
||||
|
||||
@@ -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,
|
||||
ALL_CHANNELS,
|
||||
} 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