Merge branch 'task/tec-2759-ws-residential-events' into master
Some checks failed
Deploy / Build API Image (push) Failing after 22s
Deploy / Build Web Image (push) Failing after 9s
Deploy / Build AI Services Image (push) Failing after 8s
E2E Tests / Playwright E2E (push) Failing after 16s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — Web Image (push) Failing after 33s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 31s
Deploy / Smoke Test Staging (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 0s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 10s
CI / E2E Tests (push) Has been skipped
Security Scanning / Trivy Scan — API Image (push) Failing after 52s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 34m44s
Security Scanning / Trivy Filesystem Scan (push) Failing after 37s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Some checks failed
Deploy / Build API Image (push) Failing after 22s
Deploy / Build Web Image (push) Failing after 9s
Deploy / Build AI Services Image (push) Failing after 8s
E2E Tests / Playwright E2E (push) Failing after 16s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — Web Image (push) Failing after 33s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 31s
Deploy / Smoke Test Staging (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 0s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 10s
CI / E2E Tests (push) Has been skipped
Security Scanning / Trivy Scan — API Image (push) Failing after 52s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 34m44s
Security Scanning / Trivy Filesystem Scan (push) Failing after 37s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
This commit is contained in:
@@ -37,6 +37,7 @@
|
|||||||
"@prisma/client": "^7.7.0",
|
"@prisma/client": "^7.7.0",
|
||||||
"@sentry/nestjs": "^10.47.0",
|
"@sentry/nestjs": "^10.47.0",
|
||||||
"@sentry/profiling-node": "^10.47.0",
|
"@sentry/profiling-node": "^10.47.0",
|
||||||
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"@willsoto/nestjs-prometheus": "^6.1.0",
|
"@willsoto/nestjs-prometheus": "^6.1.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bullmq": "^5.74.1",
|
"bullmq": "^5.74.1",
|
||||||
|
|||||||
@@ -8,11 +8,10 @@ import './instrument';
|
|||||||
|
|
||||||
import { RequestMethod, ValidationPipe } from '@nestjs/common';
|
import { RequestMethod, ValidationPipe } from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { IoAdapter } from '@nestjs/platform-socket.io';
|
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import { LoggerService, validateEnv } from '@modules/shared';
|
import { LoggerService, RedisIoAdapter, validateEnv } from '@modules/shared';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
@@ -60,7 +59,11 @@ async function bootstrap() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── WebSocket Adapter (Socket.IO) ──
|
// ── WebSocket Adapter (Socket.IO) ──
|
||||||
app.useWebSocketAdapter(new IoAdapter(app));
|
// Redis pub/sub fan-out for multi-instance broadcasts; falls back to the
|
||||||
|
// in-memory IoAdapter when Redis is unreachable (single-node / local dev).
|
||||||
|
const wsAdapter = new RedisIoAdapter(app);
|
||||||
|
await wsAdapter.connectToRedis();
|
||||||
|
app.useWebSocketAdapter(wsAdapter);
|
||||||
|
|
||||||
// ── Security Headers (Helmet) ──
|
// ── Security Headers (Helmet) ──
|
||||||
app.use(
|
app.use(
|
||||||
|
|||||||
@@ -2,13 +2,19 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
Ip,
|
||||||
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam, ApiQuery } from '@nestjs/swagger';
|
||||||
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
||||||
|
import {
|
||||||
|
AdminFeatureListingCommand,
|
||||||
|
type AdminFeatureListingResult,
|
||||||
|
} from '@modules/listings';
|
||||||
import { ApproveKycCommand } from '../../application/commands/approve-kyc/approve-kyc.command';
|
import { ApproveKycCommand } from '../../application/commands/approve-kyc/approve-kyc.command';
|
||||||
import { type ApproveKycResult } from '../../application/commands/approve-kyc/approve-kyc.handler';
|
import { type ApproveKycResult } from '../../application/commands/approve-kyc/approve-kyc.handler';
|
||||||
import { ApproveListingCommand } from '../../application/commands/approve-listing/approve-listing.command';
|
import { ApproveListingCommand } from '../../application/commands/approve-listing/approve-listing.command';
|
||||||
@@ -25,6 +31,7 @@ import {
|
|||||||
type ModerationQueueResult,
|
type ModerationQueueResult,
|
||||||
type KycQueueResult,
|
type KycQueueResult,
|
||||||
} from '../../domain/repositories/admin-query.repository';
|
} from '../../domain/repositories/admin-query.repository';
|
||||||
|
import { type AdminFeatureListingDto } from '../dto/admin-feature-listing.dto';
|
||||||
import { type ApproveKycDto } from '../dto/approve-kyc.dto';
|
import { type ApproveKycDto } from '../dto/approve-kyc.dto';
|
||||||
import { type ApproveListingDto } from '../dto/approve-listing.dto';
|
import { type ApproveListingDto } from '../dto/approve-listing.dto';
|
||||||
import { type BulkModerateDto } from '../dto/bulk-moderate.dto';
|
import { type BulkModerateDto } from '../dto/bulk-moderate.dto';
|
||||||
@@ -105,6 +112,33 @@ export class AdminModerationController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('listings/:id/feature')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Admin: feature or unfeature a listing manually (audited, no payment)',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: 'Listing UUID' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Listing featured state updated successfully' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Validation error' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Unauthorized – missing or invalid JWT' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Forbidden – requires ADMIN role' })
|
||||||
|
async adminFeatureListing(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: AdminFeatureListingDto,
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Ip() ip: string,
|
||||||
|
): Promise<AdminFeatureListingResult> {
|
||||||
|
return this.commandBus.execute(
|
||||||
|
new AdminFeatureListingCommand(
|
||||||
|
id,
|
||||||
|
user.sub,
|
||||||
|
dto.action,
|
||||||
|
dto.durationDays ?? null,
|
||||||
|
dto.reason,
|
||||||
|
ip ?? null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── KYC ──
|
// ── KYC ──
|
||||||
|
|
||||||
@Get('kyc')
|
@Get('kyc')
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsIn, IsInt, IsOptional, IsString, MinLength, ValidateIf } from 'class-validator';
|
||||||
|
|
||||||
|
const ALLOWED_DURATIONS = [3, 7, 14, 30, 60, 90] as const;
|
||||||
|
export type AdminFeatureDuration = (typeof ALLOWED_DURATIONS)[number];
|
||||||
|
|
||||||
|
export class AdminFeatureListingDto {
|
||||||
|
@ApiProperty({
|
||||||
|
enum: ['feature', 'unfeature'],
|
||||||
|
example: 'feature',
|
||||||
|
description: 'Bật hoặc gỡ tin nổi bật thủ công',
|
||||||
|
})
|
||||||
|
@IsIn(['feature', 'unfeature'])
|
||||||
|
action!: 'feature' | 'unfeature';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: ALLOWED_DURATIONS,
|
||||||
|
example: 7,
|
||||||
|
description: 'Số ngày featured (bắt buộc khi action=feature)',
|
||||||
|
})
|
||||||
|
@ValidateIf((o: AdminFeatureListingDto) => o.action === 'feature')
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@IsIn([...ALLOWED_DURATIONS])
|
||||||
|
@IsOptional()
|
||||||
|
durationDays?: AdminFeatureDuration;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'Đền bù lỗi hiển thị featured trong 3 ngày qua',
|
||||||
|
description: 'Lý do cho audit log (tối thiểu 5 ký tự)',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@MinLength(5)
|
||||||
|
reason!: string;
|
||||||
|
}
|
||||||
@@ -23,7 +23,10 @@ import { PrismaValuationRepository } from './infrastructure/repositories/prisma-
|
|||||||
import { AI_SERVICE_CLIENT, AiServiceClient } from './infrastructure/services/ai-service.client';
|
import { AI_SERVICE_CLIENT, AiServiceClient } from './infrastructure/services/ai-service.client';
|
||||||
import { HttpAVMService } from './infrastructure/services/http-avm.service';
|
import { HttpAVMService } from './infrastructure/services/http-avm.service';
|
||||||
import { MarketIndexCronService } from './infrastructure/services/market-index-cron.service';
|
import { MarketIndexCronService } from './infrastructure/services/market-index-cron.service';
|
||||||
import { NeighborhoodScoreServiceImpl } from './infrastructure/services/neighborhood-score.service';
|
import {
|
||||||
|
HttpNeighborhoodScoreService,
|
||||||
|
PrismaNeighborhoodScoreService,
|
||||||
|
} from './infrastructure/services/neighborhood-score.service';
|
||||||
import { PrismaAVMService } from './infrastructure/services/prisma-avm.service';
|
import { PrismaAVMService } from './infrastructure/services/prisma-avm.service';
|
||||||
import { AnalyticsController } from './presentation/controllers/analytics.controller';
|
import { AnalyticsController } from './presentation/controllers/analytics.controller';
|
||||||
import { AvmController } from './presentation/controllers/avm.controller';
|
import { AvmController } from './presentation/controllers/avm.controller';
|
||||||
@@ -66,8 +69,9 @@ const EventHandlers = [
|
|||||||
PrismaAVMService,
|
PrismaAVMService,
|
||||||
{ provide: AVM_SERVICE, useClass: HttpAVMService },
|
{ provide: AVM_SERVICE, useClass: HttpAVMService },
|
||||||
|
|
||||||
// Neighborhood scoring
|
// Neighborhood scoring: HTTP proxy → Python AI service, falls back to Prisma scoring
|
||||||
{ provide: NEIGHBORHOOD_SCORE_SERVICE, useClass: NeighborhoodScoreServiceImpl },
|
PrismaNeighborhoodScoreService,
|
||||||
|
{ provide: NEIGHBORHOOD_SCORE_SERVICE, useClass: HttpNeighborhoodScoreService },
|
||||||
|
|
||||||
// Cron
|
// Cron
|
||||||
MarketIndexCronService,
|
MarketIndexCronService,
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { NeighborhoodScoreServiceImpl } from '../services/neighborhood-score.service';
|
import {
|
||||||
|
HttpNeighborhoodScoreService,
|
||||||
|
NeighborhoodScoreServiceImpl,
|
||||||
|
PrismaNeighborhoodScoreService,
|
||||||
|
} from '../services/neighborhood-score.service';
|
||||||
|
|
||||||
describe('NeighborhoodScoreServiceImpl', () => {
|
describe('NeighborhoodScoreServiceImpl', () => {
|
||||||
let service: NeighborhoodScoreServiceImpl;
|
let service: NeighborhoodScoreServiceImpl;
|
||||||
@@ -130,3 +134,83 @@ describe('NeighborhoodScoreServiceImpl', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -91,12 +91,42 @@ export interface AiModerationResponse {
|
|||||||
cleaned_text: string | null;
|
cleaned_text: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AiNeighborhoodPOICounts {
|
||||||
|
education: number;
|
||||||
|
healthcare: number;
|
||||||
|
transport: number;
|
||||||
|
shopping: number;
|
||||||
|
greenery: number;
|
||||||
|
safety: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiNeighborhoodScoreRequest {
|
||||||
|
district: string;
|
||||||
|
city: string;
|
||||||
|
poi_counts: AiNeighborhoodPOICounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiNeighborhoodScoreResponse {
|
||||||
|
district: string;
|
||||||
|
city: string;
|
||||||
|
education_score: number;
|
||||||
|
healthcare_score: number;
|
||||||
|
transport_score: number;
|
||||||
|
shopping_score: number;
|
||||||
|
greenery_score: number;
|
||||||
|
safety_score: number;
|
||||||
|
total_score: number;
|
||||||
|
poi_counts: Record<string, number>;
|
||||||
|
algorithm_version: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const AI_SERVICE_CLIENT = Symbol('AI_SERVICE_CLIENT');
|
export const AI_SERVICE_CLIENT = Symbol('AI_SERVICE_CLIENT');
|
||||||
|
|
||||||
export interface IAiServiceClient {
|
export interface IAiServiceClient {
|
||||||
predict(req: AiPredictRequest): Promise<AiPredictResponse>;
|
predict(req: AiPredictRequest): Promise<AiPredictResponse>;
|
||||||
predictIndustrial(req: AiIndustrialPredictRequest): Promise<AiIndustrialPredictResponse>;
|
predictIndustrial(req: AiIndustrialPredictRequest): Promise<AiIndustrialPredictResponse>;
|
||||||
moderate(req: AiModerationRequest): Promise<AiModerationResponse>;
|
moderate(req: AiModerationRequest): Promise<AiModerationResponse>;
|
||||||
|
scoreNeighborhood(req: AiNeighborhoodScoreRequest): Promise<AiNeighborhoodScoreResponse>;
|
||||||
isAvailable(): Promise<boolean>;
|
isAvailable(): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +154,12 @@ export class AiServiceClient implements IAiServiceClient {
|
|||||||
return this.post<AiModerationResponse>('/moderation/check', req);
|
return this.post<AiModerationResponse>('/moderation/check', req);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async scoreNeighborhood(
|
||||||
|
req: AiNeighborhoodScoreRequest,
|
||||||
|
): Promise<AiNeighborhoodScoreResponse> {
|
||||||
|
return this.post<AiNeighborhoodScoreResponse>('/neighborhood/score', req);
|
||||||
|
}
|
||||||
|
|
||||||
async isAvailable(): Promise<boolean> {
|
async isAvailable(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.baseUrl}/health`, {
|
const response = await fetch(`${this.baseUrl}/health`, {
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Cron } from '@nestjs/schedule';
|
||||||
|
import { type PrismaService, type LoggerService } from '@modules/shared';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AvmRetrainCronService {
|
||||||
|
private readonly aiServiceUrl: string;
|
||||||
|
private readonly aiServiceApiKey: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {
|
||||||
|
this.aiServiceUrl = process.env['AI_SERVICE_URL'] ?? 'http://localhost:8000';
|
||||||
|
this.aiServiceApiKey = process.env['AI_SERVICE_API_KEY'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weekly retrain — every Sunday at 3 AM.
|
||||||
|
*
|
||||||
|
* 1. Export training data from database to the AI service
|
||||||
|
* 2. Trigger ensemble retraining via POST /avm/v2/train
|
||||||
|
* 3. Log results (version, metrics)
|
||||||
|
*/
|
||||||
|
@Cron('0 3 * * 0', { name: 'avm-v2-weekly-retrain' })
|
||||||
|
async weeklyRetrain(): Promise<void> {
|
||||||
|
this.logger.log('Starting weekly AVM v2 retrain...', 'AvmRetrainCronService');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Export training data
|
||||||
|
const trainingData = await this.exportTrainingData();
|
||||||
|
if (trainingData.length < 50) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Insufficient training data (${trainingData.length} rows). Skipping retrain.`,
|
||||||
|
'AvmRetrainCronService',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Upload training data to AI service
|
||||||
|
await this.uploadTrainingData(trainingData);
|
||||||
|
|
||||||
|
// Step 3: Trigger retraining
|
||||||
|
const result = await this.triggerRetrain();
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`AVM v2 retrain completed: version=${result.model_version}, ` +
|
||||||
|
`MAPE=${result.metrics?.mape ?? 'N/A'}%, ` +
|
||||||
|
`samples=${result.training_samples}`,
|
||||||
|
'AvmRetrainCronService',
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`AVM v2 weekly retrain failed: ${(err as Error).message}`,
|
||||||
|
undefined,
|
||||||
|
'AvmRetrainCronService',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export property + listing + market data as training rows.
|
||||||
|
*
|
||||||
|
* Each row maps to the feature columns expected by the Python
|
||||||
|
* AVM v2 training pipeline (see avm_v2_service._prepare_training_data).
|
||||||
|
*/
|
||||||
|
async exportTrainingData(): Promise<TrainingRow[]> {
|
||||||
|
const rows = await this.prisma.$queryRaw<RawTrainingRow[]>`
|
||||||
|
WITH market AS (
|
||||||
|
SELECT
|
||||||
|
mi.district,
|
||||||
|
mi.city,
|
||||||
|
mi."avgPriceM2" AS avg_price_m2,
|
||||||
|
mi."totalListings" AS listing_density,
|
||||||
|
COALESCE(mi."absorptionRate", 0) AS absorption_rate,
|
||||||
|
mi."daysOnMarket" AS dom_avg,
|
||||||
|
COALESCE(mi."yoyChange", 0) AS yoy_change
|
||||||
|
FROM "MarketIndex" mi
|
||||||
|
WHERE mi.period = (
|
||||||
|
SELECT MAX(period) FROM "MarketIndex"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
p."propertyType"::text AS property_type,
|
||||||
|
p."areaM2" AS area_m2,
|
||||||
|
COALESCE(p.bedrooms, 2) AS rooms,
|
||||||
|
COALESCE(p.floor, 0) AS floor_level,
|
||||||
|
COALESCE(p."totalFloors", p.floors, 0) AS total_floors,
|
||||||
|
COALESCE(p.direction::text, 'unknown') AS direction,
|
||||||
|
CASE
|
||||||
|
WHEN p."totalFloors" > 0 AND p."areaM2" > 0
|
||||||
|
THEN (p."totalFloors"::float * p."areaM2") / NULLIF(p."areaM2", 0)
|
||||||
|
ELSE 1.0
|
||||||
|
END AS floor_ratio,
|
||||||
|
CASE
|
||||||
|
WHEN p."yearBuilt" IS NOT NULL
|
||||||
|
THEN EXTRACT(YEAR FROM NOW())::int - p."yearBuilt"
|
||||||
|
ELSE 5
|
||||||
|
END AS building_age_years,
|
||||||
|
CASE WHEN p.amenities::text ILIKE '%elevator%' THEN 1.0 ELSE 0.0 END AS has_elevator,
|
||||||
|
CASE WHEN p.amenities::text ILIKE '%parking%' THEN 1.0 ELSE 0.0 END AS has_parking,
|
||||||
|
CASE WHEN p.amenities::text ILIKE '%pool%' THEN 1.0 ELSE 0.0 END AS has_pool,
|
||||||
|
CASE
|
||||||
|
WHEN p."legalStatus" IN ('so_do', 'so_hong', 'SO_DO', 'SO_HONG') THEN 1.0
|
||||||
|
ELSE 0.0
|
||||||
|
END AS has_legal_paper,
|
||||||
|
0.5 AS developer_reputation,
|
||||||
|
0.5 AS neighborhood_score,
|
||||||
|
COALESCE(
|
||||||
|
ST_Distance(
|
||||||
|
p.location::geography,
|
||||||
|
ST_SetSRID(ST_MakePoint(106.6297, 10.8231), 4326)::geography
|
||||||
|
) / 1000.0,
|
||||||
|
10.0
|
||||||
|
) AS distance_to_cbd_km,
|
||||||
|
COALESCE(p."metroDistanceM" / 1000.0, 5.0) AS distance_to_metro_km,
|
||||||
|
5.0 AS distance_to_school_km,
|
||||||
|
3.0 AS distance_to_hospital_km,
|
||||||
|
2.0 AS distance_to_park_km,
|
||||||
|
4.0 AS distance_to_mall_km,
|
||||||
|
0.1 AS flood_zone_risk,
|
||||||
|
COALESCE(m.avg_price_m2, 0) AS avg_price_district_3m_vnd_m2,
|
||||||
|
COALESCE(m.listing_density, 0) AS listing_density,
|
||||||
|
COALESCE(m.absorption_rate, 0) AS absorption_rate,
|
||||||
|
COALESCE(m.dom_avg, 30) AS dom_avg,
|
||||||
|
0.0 AS price_momentum_30d,
|
||||||
|
COALESCE(m.yoy_change, 0) AS yoy_change,
|
||||||
|
0.5 AS renovation_score,
|
||||||
|
0.5 AS view_quality,
|
||||||
|
0.5 AS interior_quality,
|
||||||
|
0.3 AS noise_level,
|
||||||
|
0.5 AS natural_light,
|
||||||
|
EXTRACT(MONTH FROM l."publishedAt")::int AS month,
|
||||||
|
p.district AS district,
|
||||||
|
l."priceVND"::float AS price_vnd
|
||||||
|
FROM "Listing" l
|
||||||
|
JOIN "Property" p ON l."propertyId" = p.id
|
||||||
|
LEFT JOIN market m ON m.district = p.district AND m.city = p.city
|
||||||
|
WHERE l.status IN ('ACTIVE', 'SOLD', 'RENTED')
|
||||||
|
AND l."priceVND" > 100000000
|
||||||
|
AND l."publishedAt" IS NOT NULL
|
||||||
|
AND p."areaM2" > 0
|
||||||
|
ORDER BY l."publishedAt" DESC
|
||||||
|
LIMIT 50000
|
||||||
|
`;
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
property_type: String(r.property_type).toLowerCase(),
|
||||||
|
area_m2: Number(r.area_m2),
|
||||||
|
rooms: Number(r.rooms),
|
||||||
|
floor_level: Number(r.floor_level),
|
||||||
|
total_floors: Number(r.total_floors),
|
||||||
|
direction: String(r.direction).toLowerCase(),
|
||||||
|
floor_ratio: Number(r.floor_ratio),
|
||||||
|
building_age_years: Number(r.building_age_years),
|
||||||
|
has_elevator: Number(r.has_elevator),
|
||||||
|
has_parking: Number(r.has_parking),
|
||||||
|
has_pool: Number(r.has_pool),
|
||||||
|
has_legal_paper: Number(r.has_legal_paper),
|
||||||
|
developer_reputation: Number(r.developer_reputation),
|
||||||
|
neighborhood_score: Number(r.neighborhood_score),
|
||||||
|
distance_to_cbd_km: Number(r.distance_to_cbd_km),
|
||||||
|
distance_to_metro_km: Number(r.distance_to_metro_km),
|
||||||
|
distance_to_school_km: Number(r.distance_to_school_km),
|
||||||
|
distance_to_hospital_km: Number(r.distance_to_hospital_km),
|
||||||
|
distance_to_park_km: Number(r.distance_to_park_km),
|
||||||
|
distance_to_mall_km: Number(r.distance_to_mall_km),
|
||||||
|
flood_zone_risk: Number(r.flood_zone_risk),
|
||||||
|
avg_price_district_3m_vnd_m2: Number(r.avg_price_district_3m_vnd_m2),
|
||||||
|
listing_density: Number(r.listing_density),
|
||||||
|
absorption_rate: Number(r.absorption_rate),
|
||||||
|
dom_avg: Number(r.dom_avg),
|
||||||
|
price_momentum_30d: Number(r.price_momentum_30d),
|
||||||
|
yoy_change: Number(r.yoy_change),
|
||||||
|
renovation_score: Number(r.renovation_score),
|
||||||
|
view_quality: Number(r.view_quality),
|
||||||
|
interior_quality: Number(r.interior_quality),
|
||||||
|
noise_level: Number(r.noise_level),
|
||||||
|
natural_light: Number(r.natural_light),
|
||||||
|
month: Number(r.month),
|
||||||
|
district: String(r.district),
|
||||||
|
price_vnd: Number(r.price_vnd),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async uploadTrainingData(rows: TrainingRow[]): Promise<void> {
|
||||||
|
const headers = Object.keys(rows[0]!);
|
||||||
|
const csvLines = [headers.join(',')];
|
||||||
|
for (const row of rows) {
|
||||||
|
csvLines.push(headers.map((h) => String(row[h as keyof TrainingRow])).join(','));
|
||||||
|
}
|
||||||
|
const csv = csvLines.join('\n');
|
||||||
|
|
||||||
|
const url = `${this.aiServiceUrl}/avm/v2/upload-training-data`;
|
||||||
|
const reqHeaders: Record<string, string> = { 'Content-Type': 'text/csv' };
|
||||||
|
if (this.aiServiceApiKey) {
|
||||||
|
reqHeaders['X-API-Key'] = this.aiServiceApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: reqHeaders,
|
||||||
|
body: csv,
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => '');
|
||||||
|
throw new Error(`Training data upload failed (${response.status}): ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Uploaded ${rows.length} training rows to AI service`,
|
||||||
|
'AvmRetrainCronService',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async triggerRetrain(): Promise<RetrainResult> {
|
||||||
|
const url = `${this.aiServiceUrl}/avm/v2/train`;
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (this.aiServiceApiKey) {
|
||||||
|
headers['X-API-Key'] = this.aiServiceApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
optuna_trials: 50,
|
||||||
|
test_size: 0.15,
|
||||||
|
val_size: 0.15,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(600_000), // 10 min — training can take a while
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => '');
|
||||||
|
throw new Error(`Retrain request failed (${response.status}): ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<RetrainResult>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawTrainingRow {
|
||||||
|
property_type: string;
|
||||||
|
area_m2: number;
|
||||||
|
rooms: number;
|
||||||
|
floor_level: number;
|
||||||
|
total_floors: number;
|
||||||
|
direction: string;
|
||||||
|
floor_ratio: number;
|
||||||
|
building_age_years: number;
|
||||||
|
has_elevator: number;
|
||||||
|
has_parking: number;
|
||||||
|
has_pool: number;
|
||||||
|
has_legal_paper: number;
|
||||||
|
developer_reputation: number;
|
||||||
|
neighborhood_score: number;
|
||||||
|
distance_to_cbd_km: number;
|
||||||
|
distance_to_metro_km: number;
|
||||||
|
distance_to_school_km: number;
|
||||||
|
distance_to_hospital_km: number;
|
||||||
|
distance_to_park_km: number;
|
||||||
|
distance_to_mall_km: number;
|
||||||
|
flood_zone_risk: number;
|
||||||
|
avg_price_district_3m_vnd_m2: number;
|
||||||
|
listing_density: number;
|
||||||
|
absorption_rate: number;
|
||||||
|
dom_avg: number;
|
||||||
|
price_momentum_30d: number;
|
||||||
|
yoy_change: number;
|
||||||
|
renovation_score: number;
|
||||||
|
view_quality: number;
|
||||||
|
interior_quality: number;
|
||||||
|
noise_level: number;
|
||||||
|
natural_light: number;
|
||||||
|
month: number;
|
||||||
|
district: string;
|
||||||
|
price_vnd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrainingRow extends RawTrainingRow {}
|
||||||
|
|
||||||
|
interface RetrainResult {
|
||||||
|
model_version: string;
|
||||||
|
metrics: {
|
||||||
|
mae: number;
|
||||||
|
mape: number;
|
||||||
|
rmse: number;
|
||||||
|
r2: number;
|
||||||
|
};
|
||||||
|
training_samples: number;
|
||||||
|
validation_samples: number;
|
||||||
|
test_samples: number;
|
||||||
|
best_params: Record<string, unknown>;
|
||||||
|
}
|
||||||
@@ -1,13 +1,20 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { POIType } from '@prisma/client';
|
||||||
import { type PrismaService, type LoggerService } from '@modules/shared';
|
import { type PrismaService, type LoggerService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
type INeighborhoodScoreService,
|
type INeighborhoodScoreService,
|
||||||
type NeighborhoodScoreResult,
|
type NeighborhoodScoreResult,
|
||||||
} from '../../domain/services/neighborhood-score.service';
|
} from '../../domain/services/neighborhood-score.service';
|
||||||
|
import {
|
||||||
|
AI_SERVICE_CLIENT,
|
||||||
|
type AiNeighborhoodPOICounts,
|
||||||
|
type IAiServiceClient,
|
||||||
|
} from './ai-service.client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scoring weights for each POI category.
|
* Scoring weights for each POI category.
|
||||||
* Sum = 100 (total score is 0–100 weighted average).
|
* Sum = 100 (total score is 0–100 weighted average).
|
||||||
|
* Mirrors the Python heuristic in libs/ai-services/app/services/neighborhood_service.py.
|
||||||
*/
|
*/
|
||||||
const CATEGORY_WEIGHTS = {
|
const CATEGORY_WEIGHTS = {
|
||||||
education: 20,
|
education: 20,
|
||||||
@@ -16,20 +23,20 @@ const CATEGORY_WEIGHTS = {
|
|||||||
shopping: 15,
|
shopping: 15,
|
||||||
greenery: 15,
|
greenery: 15,
|
||||||
safety: 10,
|
safety: 10,
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
/** POI types grouped by scoring category. */
|
/** POI types grouped by scoring category. */
|
||||||
const CATEGORY_POI_TYPES: Record<string, string[]> = {
|
const CATEGORY_POI_TYPES: Record<keyof typeof CATEGORY_WEIGHTS, POIType[]> = {
|
||||||
education: ['SCHOOL', 'UNIVERSITY'],
|
education: [POIType.SCHOOL, POIType.UNIVERSITY],
|
||||||
healthcare: ['HOSPITAL', 'CLINIC'],
|
healthcare: [POIType.HOSPITAL, POIType.CLINIC],
|
||||||
transport: ['METRO_STATION', 'BUS_STOP'],
|
transport: [POIType.METRO_STATION, POIType.BUS_STOP],
|
||||||
shopping: ['MALL', 'MARKET', 'SUPERMARKET'],
|
shopping: [POIType.MALL, POIType.MARKET, POIType.SUPERMARKET],
|
||||||
greenery: ['PARK'],
|
greenery: [POIType.PARK],
|
||||||
safety: ['POLICE_STATION', 'FIRE_STATION'],
|
safety: [POIType.POLICE_STATION, POIType.FIRE_STATION],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Max count per category that yields a 10/10 score. */
|
/** Max count per category that yields a 10/10 score. */
|
||||||
const MAX_COUNTS: Record<string, number> = {
|
const MAX_COUNTS: Record<keyof typeof CATEGORY_WEIGHTS, number> = {
|
||||||
education: 15,
|
education: 15,
|
||||||
healthcare: 8,
|
healthcare: 8,
|
||||||
transport: 12,
|
transport: 12,
|
||||||
@@ -38,8 +45,11 @@ const MAX_COUNTS: Record<string, number> = {
|
|||||||
safety: 4,
|
safety: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CategoryKey = keyof typeof CATEGORY_WEIGHTS;
|
||||||
|
const CATEGORY_KEYS = Object.keys(CATEGORY_WEIGHTS) as CategoryKey[];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NeighborhoodScoreServiceImpl implements INeighborhoodScoreService {
|
export class PrismaNeighborhoodScoreService implements INeighborhoodScoreService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly logger: LoggerService,
|
private readonly logger: LoggerService,
|
||||||
@@ -52,91 +62,179 @@ export class NeighborhoodScoreServiceImpl implements INeighborhoodScoreService {
|
|||||||
|
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
||||||
return {
|
return mapRecord(existing);
|
||||||
district: existing.district,
|
|
||||||
city: existing.city,
|
|
||||||
educationScore: existing.educationScore,
|
|
||||||
healthcareScore: existing.healthcareScore,
|
|
||||||
transportScore: existing.transportScore,
|
|
||||||
shoppingScore: existing.shoppingScore,
|
|
||||||
greeneryScore: existing.greeneryScore,
|
|
||||||
safetyScore: existing.safetyScore,
|
|
||||||
totalScore: existing.totalScore,
|
|
||||||
poiCounts: existing.poiCounts as Record<string, number>,
|
|
||||||
calculatedAt: existing.calculatedAt,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async calculateAndSave(district: string, city: string): Promise<NeighborhoodScoreResult> {
|
async calculateAndSave(district: string, city: string): Promise<NeighborhoodScoreResult> {
|
||||||
// Count POIs per category for this district
|
const counts = await countPOIs(this.prisma, district, city);
|
||||||
const poiCounts: Record<string, number> = {};
|
const subScores = scoreFromCounts(counts);
|
||||||
const categoryScores: Record<string, number> = {};
|
const totalScore = weightedTotal(subScores);
|
||||||
|
|
||||||
for (const [category, poiTypes] of Object.entries(CATEGORY_POI_TYPES)) {
|
const result = await upsertScore(this.prisma, district, city, subScores, totalScore, counts);
|
||||||
const count = await this.prisma.pOI.count({
|
this.logger.log(
|
||||||
|
`Neighborhood score (prisma) calculated: ${district}, ${city} → total=${result.totalScore}`,
|
||||||
|
'NeighborhoodScoreService',
|
||||||
|
);
|
||||||
|
return mapRecord(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls the Python AI service to compute scores; falls back to local Prisma scoring
|
||||||
|
* when the service is unavailable or the call times out. Persists to NeighborhoodScore.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class HttpNeighborhoodScoreService implements INeighborhoodScoreService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
@Inject(AI_SERVICE_CLIENT) private readonly aiClient: IAiServiceClient,
|
||||||
|
private readonly fallback: PrismaNeighborhoodScoreService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getScore(district: string, city: string): Promise<NeighborhoodScoreResult | null> {
|
||||||
|
return this.fallback.getScore(district, city);
|
||||||
|
}
|
||||||
|
|
||||||
|
async calculateAndSave(district: string, city: string): Promise<NeighborhoodScoreResult> {
|
||||||
|
const counts = await countPOIs(this.prisma, district, city);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const aiResult = await this.aiClient.scoreNeighborhood({
|
||||||
|
district,
|
||||||
|
city,
|
||||||
|
poi_counts: counts,
|
||||||
|
});
|
||||||
|
|
||||||
|
const subScores: Record<CategoryKey, number> = {
|
||||||
|
education: aiResult.education_score,
|
||||||
|
healthcare: aiResult.healthcare_score,
|
||||||
|
transport: aiResult.transport_score,
|
||||||
|
shopping: aiResult.shopping_score,
|
||||||
|
greenery: aiResult.greenery_score,
|
||||||
|
safety: aiResult.safety_score,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await upsertScore(
|
||||||
|
this.prisma,
|
||||||
|
district,
|
||||||
|
city,
|
||||||
|
subScores,
|
||||||
|
aiResult.total_score,
|
||||||
|
counts,
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`Neighborhood score (ai=${aiResult.algorithm_version}): ${district}, ${city} → total=${result.totalScore}`,
|
||||||
|
'NeighborhoodScoreService',
|
||||||
|
);
|
||||||
|
return mapRecord(result);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`AI neighborhood score unavailable, falling back to prisma scoring: ${(err as Error).message}`,
|
||||||
|
'NeighborhoodScoreService',
|
||||||
|
);
|
||||||
|
return this.fallback.calculateAndSave(district, city);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countPOIs(
|
||||||
|
prisma: PrismaService,
|
||||||
|
district: string,
|
||||||
|
city: string,
|
||||||
|
): Promise<AiNeighborhoodPOICounts> {
|
||||||
|
const entries = await Promise.all(
|
||||||
|
CATEGORY_KEYS.map(async (cat) => {
|
||||||
|
const count = await prisma.pOI.count({
|
||||||
where: {
|
where: {
|
||||||
district,
|
district,
|
||||||
city,
|
city,
|
||||||
type: { in: poiTypes as any },
|
type: { in: CATEGORY_POI_TYPES[cat] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return [cat, count] as const;
|
||||||
poiCounts[category] = count;
|
}),
|
||||||
// Score 0–10: linear scale capped at MAX_COUNTS
|
|
||||||
const maxCount = MAX_COUNTS[category]!;
|
|
||||||
categoryScores[category] = Math.min(10, (count / maxCount) * 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Weighted total score (0–100)
|
|
||||||
const totalScore = Object.entries(CATEGORY_WEIGHTS).reduce((sum, [cat, weight]) => {
|
|
||||||
return sum + (categoryScores[cat]! * weight) / 10;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
const result = await this.prisma.neighborhoodScore.upsert({
|
|
||||||
where: { district_city: { district, city } },
|
|
||||||
create: {
|
|
||||||
district,
|
|
||||||
city,
|
|
||||||
educationScore: categoryScores['education']!,
|
|
||||||
healthcareScore: categoryScores['healthcare']!,
|
|
||||||
transportScore: categoryScores['transport']!,
|
|
||||||
shoppingScore: categoryScores['shopping']!,
|
|
||||||
greeneryScore: categoryScores['greenery']!,
|
|
||||||
safetyScore: categoryScores['safety']!,
|
|
||||||
totalScore: Math.round(totalScore * 10) / 10,
|
|
||||||
poiCounts,
|
|
||||||
calculatedAt: new Date(),
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
educationScore: categoryScores['education']!,
|
|
||||||
healthcareScore: categoryScores['healthcare']!,
|
|
||||||
transportScore: categoryScores['transport']!,
|
|
||||||
shoppingScore: categoryScores['shopping']!,
|
|
||||||
greeneryScore: categoryScores['greenery']!,
|
|
||||||
safetyScore: categoryScores['safety']!,
|
|
||||||
totalScore: Math.round(totalScore * 10) / 10,
|
|
||||||
poiCounts,
|
|
||||||
calculatedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Neighborhood score calculated: ${district}, ${city} → total=${result.totalScore}`,
|
|
||||||
'NeighborhoodScoreService',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return Object.fromEntries(entries) as unknown as AiNeighborhoodPOICounts;
|
||||||
district: result.district,
|
|
||||||
city: result.city,
|
|
||||||
educationScore: result.educationScore,
|
|
||||||
healthcareScore: result.healthcareScore,
|
|
||||||
transportScore: result.transportScore,
|
|
||||||
shoppingScore: result.shoppingScore,
|
|
||||||
greeneryScore: result.greeneryScore,
|
|
||||||
safetyScore: result.safetyScore,
|
|
||||||
totalScore: result.totalScore,
|
|
||||||
poiCounts: result.poiCounts as Record<string, number>,
|
|
||||||
calculatedAt: result.calculatedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scoreFromCounts(counts: AiNeighborhoodPOICounts): Record<CategoryKey, number> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
CATEGORY_KEYS.map((cat) => {
|
||||||
|
const raw = counts[cat] ?? 0;
|
||||||
|
const max = MAX_COUNTS[cat];
|
||||||
|
return [cat, Math.min(10, (raw / max) * 10)];
|
||||||
|
}),
|
||||||
|
) as Record<CategoryKey, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function weightedTotal(subScores: Record<CategoryKey, number>): number {
|
||||||
|
const sum = CATEGORY_KEYS.reduce(
|
||||||
|
(acc, cat) => acc + (subScores[cat] * CATEGORY_WEIGHTS[cat]) / 10,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return Math.round(sum * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertScore(
|
||||||
|
prisma: PrismaService,
|
||||||
|
district: string,
|
||||||
|
city: string,
|
||||||
|
subScores: Record<CategoryKey, number>,
|
||||||
|
totalScore: number,
|
||||||
|
counts: AiNeighborhoodPOICounts,
|
||||||
|
) {
|
||||||
|
const calculatedAt = new Date();
|
||||||
|
const data = {
|
||||||
|
educationScore: subScores.education,
|
||||||
|
healthcareScore: subScores.healthcare,
|
||||||
|
transportScore: subScores.transport,
|
||||||
|
shoppingScore: subScores.shopping,
|
||||||
|
greeneryScore: subScores.greenery,
|
||||||
|
safetyScore: subScores.safety,
|
||||||
|
totalScore,
|
||||||
|
poiCounts: counts as unknown as Record<string, number>,
|
||||||
|
calculatedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
return prisma.neighborhoodScore.upsert({
|
||||||
|
where: { district_city: { district, city } },
|
||||||
|
create: { district, city, ...data },
|
||||||
|
update: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRecord(record: {
|
||||||
|
district: string;
|
||||||
|
city: string;
|
||||||
|
educationScore: number;
|
||||||
|
healthcareScore: number;
|
||||||
|
transportScore: number;
|
||||||
|
shoppingScore: number;
|
||||||
|
greeneryScore: number;
|
||||||
|
safetyScore: number;
|
||||||
|
totalScore: number;
|
||||||
|
poiCounts: unknown;
|
||||||
|
calculatedAt: Date;
|
||||||
|
}): NeighborhoodScoreResult {
|
||||||
|
return {
|
||||||
|
district: record.district,
|
||||||
|
city: record.city,
|
||||||
|
educationScore: record.educationScore,
|
||||||
|
healthcareScore: record.healthcareScore,
|
||||||
|
transportScore: record.transportScore,
|
||||||
|
shoppingScore: record.shoppingScore,
|
||||||
|
greeneryScore: record.greeneryScore,
|
||||||
|
safetyScore: record.safetyScore,
|
||||||
|
totalScore: record.totalScore,
|
||||||
|
poiCounts: record.poiCounts as Record<string, number>,
|
||||||
|
calculatedAt: record.calculatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use HttpNeighborhoodScoreService (binds AI proxy + prisma fallback).
|
||||||
|
* Kept exported for backward compatibility with callers/tests.
|
||||||
|
*/
|
||||||
|
export { PrismaNeighborhoodScoreService as NeighborhoodScoreServiceImpl };
|
||||||
|
|||||||
@@ -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,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,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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -142,6 +142,33 @@ describe('ListingEntity', () => {
|
|||||||
const fields = listing.updateContent({});
|
const fields = listing.updateContent({});
|
||||||
expect(fields).toEqual([]);
|
expect(fields).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should emit ListingPriceChangedEvent when price actually changes', () => {
|
||||||
|
const listing = makeDefaultListing();
|
||||||
|
listing.clearDomainEvents();
|
||||||
|
|
||||||
|
listing.updateContent({ priceVND: 6_000_000_000n, areaM2: 100 });
|
||||||
|
|
||||||
|
const events = listing.domainEvents;
|
||||||
|
const priceEvent = events.find((e) => e.eventName === 'listing.price_changed');
|
||||||
|
expect(priceEvent).toBeDefined();
|
||||||
|
expect((priceEvent as { oldPrice: bigint; newPrice: bigint }).oldPrice).toBe(
|
||||||
|
5_000_000_000n,
|
||||||
|
);
|
||||||
|
expect((priceEvent as { oldPrice: bigint; newPrice: bigint }).newPrice).toBe(
|
||||||
|
6_000_000_000n,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT emit ListingPriceChangedEvent when price stays the same', () => {
|
||||||
|
const listing = makeDefaultListing();
|
||||||
|
listing.clearDomainEvents();
|
||||||
|
|
||||||
|
listing.updateContent({ priceVND: 5_000_000_000n, areaM2: 100 });
|
||||||
|
|
||||||
|
const events = listing.domainEvents;
|
||||||
|
expect(events.some((e) => e.eventName === 'listing.price_changed')).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('markEditedForReModeration', () => {
|
describe('markEditedForReModeration', () => {
|
||||||
|
|||||||
@@ -2,6 +2,19 @@ export { ListingsModule } from './listings.module';
|
|||||||
export { ListingEntity, type ListingProps } from './domain/entities/listing.entity';
|
export { ListingEntity, type ListingProps } from './domain/entities/listing.entity';
|
||||||
export { ListingCreatedEvent } from './domain/events/listing-created.event';
|
export { ListingCreatedEvent } from './domain/events/listing-created.event';
|
||||||
export { ModerateListingCommand } from './application/commands/moderate-listing/moderate-listing.command';
|
export { ModerateListingCommand } from './application/commands/moderate-listing/moderate-listing.command';
|
||||||
|
export {
|
||||||
|
AdminFeatureListingCommand,
|
||||||
|
type AdminFeatureAction,
|
||||||
|
} from './application/commands/admin-feature-listing/admin-feature-listing.command';
|
||||||
|
export { type AdminFeatureListingResult } from './application/commands/admin-feature-listing/admin-feature-listing.handler';
|
||||||
|
export {
|
||||||
|
PromoteFeaturedListingCommand,
|
||||||
|
type PromoteFeaturedDuration,
|
||||||
|
} from './application/commands/promote-featured-listing/promote-featured-listing.command';
|
||||||
|
export {
|
||||||
|
type PromoteFeaturedListingResult,
|
||||||
|
FEATURED_LISTINGS_PROMOTED_METRIC,
|
||||||
|
} from './application/commands/promote-featured-listing/promote-featured-listing.handler';
|
||||||
export { LISTING_REPOSITORY, IListingRepository, type ListingSearchParams, type PaginatedResult } from './domain/repositories/listing.repository';
|
export { LISTING_REPOSITORY, IListingRepository, type ListingSearchParams, type PaginatedResult } from './domain/repositories/listing.repository';
|
||||||
export { ListingPriceChangedEvent } from './domain/events/listing-price-changed.event';
|
export { ListingPriceChangedEvent } from './domain/events/listing-price-changed.event';
|
||||||
export { ListingSoldEvent } from './domain/events/listing-sold.event';
|
export { ListingSoldEvent } from './domain/events/listing-sold.event';
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { CqrsModule } from '@nestjs/cqrs';
|
import { CqrsModule } from '@nestjs/cqrs';
|
||||||
import { MulterModule } from '@nestjs/platform-express';
|
import { MulterModule } from '@nestjs/platform-express';
|
||||||
|
import { AdminFeatureListingHandler } from './application/commands/admin-feature-listing/admin-feature-listing.handler';
|
||||||
import { CreateListingHandler } from './application/commands/create-listing/create-listing.handler';
|
import { CreateListingHandler } from './application/commands/create-listing/create-listing.handler';
|
||||||
import { FeatureListingHandler } from './application/commands/feature-listing/feature-listing.handler';
|
import { FeatureListingHandler } from './application/commands/feature-listing/feature-listing.handler';
|
||||||
import { ModerateListingHandler } from './application/commands/moderate-listing/moderate-listing.handler';
|
import { ModerateListingHandler } from './application/commands/moderate-listing/moderate-listing.handler';
|
||||||
|
import { PromoteFeaturedListingHandler } from './application/commands/promote-featured-listing/promote-featured-listing.handler';
|
||||||
import { UpdateListingHandler } from './application/commands/update-listing/update-listing.handler';
|
import { UpdateListingHandler } from './application/commands/update-listing/update-listing.handler';
|
||||||
import { UpdateListingStatusHandler } from './application/commands/update-listing-status/update-listing-status.handler';
|
import { UpdateListingStatusHandler } from './application/commands/update-listing-status/update-listing-status.handler';
|
||||||
import { UploadMediaHandler } from './application/commands/upload-media/upload-media.handler';
|
import { UploadMediaHandler } from './application/commands/upload-media/upload-media.handler';
|
||||||
@@ -28,6 +30,8 @@ import { ListingsController } from './presentation/controllers/listings.controll
|
|||||||
const CommandHandlers = [
|
const CommandHandlers = [
|
||||||
CreateListingHandler,
|
CreateListingHandler,
|
||||||
FeatureListingHandler,
|
FeatureListingHandler,
|
||||||
|
PromoteFeaturedListingHandler,
|
||||||
|
AdminFeatureListingHandler,
|
||||||
UpdateListingHandler,
|
UpdateListingHandler,
|
||||||
UpdateListingStatusHandler,
|
UpdateListingStatusHandler,
|
||||||
UploadMediaHandler,
|
UploadMediaHandler,
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import type { CreateListingResult } from '../../application/commands/create-list
|
|||||||
import { FeatureListingCommand } from '../../application/commands/feature-listing/feature-listing.command';
|
import { FeatureListingCommand } from '../../application/commands/feature-listing/feature-listing.command';
|
||||||
import type { FeatureListingResult } from '../../application/commands/feature-listing/feature-listing.handler';
|
import type { FeatureListingResult } from '../../application/commands/feature-listing/feature-listing.handler';
|
||||||
import { ModerateListingCommand } from '../../application/commands/moderate-listing/moderate-listing.command';
|
import { ModerateListingCommand } from '../../application/commands/moderate-listing/moderate-listing.command';
|
||||||
|
import { PromoteFeaturedListingCommand } from '../../application/commands/promote-featured-listing/promote-featured-listing.command';
|
||||||
|
import type { PromoteFeaturedListingResult } from '../../application/commands/promote-featured-listing/promote-featured-listing.handler';
|
||||||
import { UpdateListingCommand } from '../../application/commands/update-listing/update-listing.command';
|
import { UpdateListingCommand } from '../../application/commands/update-listing/update-listing.command';
|
||||||
import type { UpdateListingResult } from '../../application/commands/update-listing/update-listing.handler';
|
import type { UpdateListingResult } from '../../application/commands/update-listing/update-listing.handler';
|
||||||
import { UpdateListingStatusCommand } from '../../application/commands/update-listing-status/update-listing-status.command';
|
import { UpdateListingStatusCommand } from '../../application/commands/update-listing-status/update-listing-status.command';
|
||||||
@@ -47,6 +49,7 @@ import type { PaginatedResult } from '../../domain/repositories/listing.reposito
|
|||||||
import type { CreateListingDto } from '../dto/create-listing.dto';
|
import type { CreateListingDto } from '../dto/create-listing.dto';
|
||||||
import type { FeatureListingDto } from '../dto/feature-listing.dto';
|
import type { FeatureListingDto } from '../dto/feature-listing.dto';
|
||||||
import type { ModerateListingDto } from '../dto/moderate-listing.dto';
|
import type { ModerateListingDto } from '../dto/moderate-listing.dto';
|
||||||
|
import type { PromoteFeaturedListingDto } from '../dto/promote-featured-listing.dto';
|
||||||
import { type SearchListingsDto } from '../dto/search-listings.dto';
|
import { type SearchListingsDto } from '../dto/search-listings.dto';
|
||||||
import type { UpdateListingStatusDto } from '../dto/update-listing-status.dto';
|
import type { UpdateListingStatusDto } from '../dto/update-listing-status.dto';
|
||||||
import type { UpdateListingDto } from '../dto/update-listing.dto';
|
import type { UpdateListingDto } from '../dto/update-listing.dto';
|
||||||
@@ -319,4 +322,28 @@ export class ListingsController {
|
|||||||
new FeatureListingCommand(id, user.sub, dto.package, dto.provider, dto.returnUrl, ip),
|
new FeatureListingCommand(id, user.sub, dto.package, dto.provider, dto.returnUrl, ip),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('JWT')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Promote a listing via subscription entitlement (no payment)',
|
||||||
|
description:
|
||||||
|
'Sử dụng quota `featured_listings_promoted` của subscription để bật featured không qua thanh toán.',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: 'Listing UUID' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Listing promoted successfully' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Invalid duration or listing not ACTIVE' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Not owner/agent or quota exhausted' })
|
||||||
|
@UseGuards(JwtAuthGuard, QuotaGuard)
|
||||||
|
@RequireQuota('featured_listings_promoted')
|
||||||
|
@Post(':id/promote')
|
||||||
|
async promoteListing(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: PromoteFeaturedListingDto,
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
): Promise<PromoteFeaturedListingResult> {
|
||||||
|
return this.commandBus.execute(
|
||||||
|
new PromoteFeaturedListingCommand(id, user.sub, dto.durationDays),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsIn, IsInt } from 'class-validator';
|
||||||
|
import { type PromoteFeaturedDuration } from '../../application/commands/promote-featured-listing/promote-featured-listing.command';
|
||||||
|
|
||||||
|
const ALLOWED_DURATIONS: readonly number[] = [3, 7, 14, 30];
|
||||||
|
|
||||||
|
export class PromoteFeaturedListingDto {
|
||||||
|
@ApiProperty({
|
||||||
|
enum: ALLOWED_DURATIONS,
|
||||||
|
example: 7,
|
||||||
|
description: 'Số ngày đẩy nổi bật (dùng quota subscription, không phát sinh thanh toán)',
|
||||||
|
})
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@IsIn([...ALLOWED_DURATIONS])
|
||||||
|
durationDays!: PromoteFeaturedDuration;
|
||||||
|
}
|
||||||
@@ -9,6 +9,11 @@ describe('MetricsService', () => {
|
|||||||
let mockSearchQueriesCounter: { inc: ReturnType<typeof vi.fn> };
|
let mockSearchQueriesCounter: { inc: ReturnType<typeof vi.fn> };
|
||||||
let mockRequestDurationHistogram: { observe: ReturnType<typeof vi.fn> };
|
let mockRequestDurationHistogram: { observe: ReturnType<typeof vi.fn> };
|
||||||
let mockHttpRequestsCounter: { inc: ReturnType<typeof vi.fn> };
|
let mockHttpRequestsCounter: { inc: ReturnType<typeof vi.fn> };
|
||||||
|
let mockWsConnectedClientsGauge: {
|
||||||
|
inc: ReturnType<typeof vi.fn>;
|
||||||
|
set: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let mockWsMessagesCounter: { inc: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockListingsCreatedCounter = { inc: vi.fn() };
|
mockListingsCreatedCounter = { inc: vi.fn() };
|
||||||
@@ -17,6 +22,8 @@ describe('MetricsService', () => {
|
|||||||
mockSearchQueriesCounter = { inc: vi.fn() };
|
mockSearchQueriesCounter = { inc: vi.fn() };
|
||||||
mockRequestDurationHistogram = { observe: vi.fn() };
|
mockRequestDurationHistogram = { observe: vi.fn() };
|
||||||
mockHttpRequestsCounter = { inc: vi.fn() };
|
mockHttpRequestsCounter = { inc: vi.fn() };
|
||||||
|
mockWsConnectedClientsGauge = { inc: vi.fn(), set: vi.fn() };
|
||||||
|
mockWsMessagesCounter = { inc: vi.fn() };
|
||||||
|
|
||||||
service = new MetricsService(
|
service = new MetricsService(
|
||||||
mockListingsCreatedCounter as unknown as Counter,
|
mockListingsCreatedCounter as unknown as Counter,
|
||||||
@@ -25,6 +32,8 @@ describe('MetricsService', () => {
|
|||||||
mockSearchQueriesCounter as unknown as Counter,
|
mockSearchQueriesCounter as unknown as Counter,
|
||||||
mockRequestDurationHistogram as unknown as Histogram,
|
mockRequestDurationHistogram as unknown as Histogram,
|
||||||
mockHttpRequestsCounter as unknown as Counter,
|
mockHttpRequestsCounter as unknown as Counter,
|
||||||
|
mockWsConnectedClientsGauge as unknown as Gauge,
|
||||||
|
mockWsMessagesCounter as unknown as Counter,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -102,4 +111,41 @@ describe('MetricsService', () => {
|
|||||||
expect.objectContaining({ status_code: '503' }),
|
expect.objectContaining({ status_code: '503' }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('recordWsConnection increments the connected-clients gauge with +1 on connect', () => {
|
||||||
|
service.recordWsConnection('/notifications', 1);
|
||||||
|
|
||||||
|
expect(mockWsConnectedClientsGauge.inc).toHaveBeenCalledWith(
|
||||||
|
{ namespace: '/notifications' },
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recordWsConnection decrements the connected-clients gauge with -1 on disconnect', () => {
|
||||||
|
service.recordWsConnection('/notifications', -1);
|
||||||
|
|
||||||
|
expect(mockWsConnectedClientsGauge.inc).toHaveBeenCalledWith(
|
||||||
|
{ namespace: '/notifications' },
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setWsConnectedClients sets the gauge for a namespace', () => {
|
||||||
|
service.setWsConnectedClients('/notifications', 0);
|
||||||
|
|
||||||
|
expect(mockWsConnectedClientsGauge.set).toHaveBeenCalledWith(
|
||||||
|
{ namespace: '/notifications' },
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recordWsMessage increments the messages counter with namespace/event/direction', () => {
|
||||||
|
service.recordWsMessage('/notifications', 'notification:new', 'out');
|
||||||
|
|
||||||
|
expect(mockWsMessagesCounter.inc).toHaveBeenCalledWith({
|
||||||
|
namespace: '/notifications',
|
||||||
|
event: 'notification:new',
|
||||||
|
direction: 'out',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
GOODGO_SEARCH_QUERIES_TOTAL,
|
GOODGO_SEARCH_QUERIES_TOTAL,
|
||||||
GOODGO_API_REQUEST_DURATION,
|
GOODGO_API_REQUEST_DURATION,
|
||||||
HTTP_REQUESTS_TOTAL,
|
HTTP_REQUESTS_TOTAL,
|
||||||
|
GOODGO_WS_CONNECTED_CLIENTS,
|
||||||
|
GOODGO_WS_MESSAGES_TOTAL,
|
||||||
WEB_VITALS_LCP,
|
WEB_VITALS_LCP,
|
||||||
WEB_VITALS_FCP,
|
WEB_VITALS_FCP,
|
||||||
WEB_VITALS_CLS,
|
WEB_VITALS_CLS,
|
||||||
@@ -31,6 +33,10 @@ export class MetricsService {
|
|||||||
private readonly requestDurationHistogram: Histogram,
|
private readonly requestDurationHistogram: Histogram,
|
||||||
@InjectMetric(HTTP_REQUESTS_TOTAL)
|
@InjectMetric(HTTP_REQUESTS_TOTAL)
|
||||||
private readonly httpRequestsCounter: Counter,
|
private readonly httpRequestsCounter: Counter,
|
||||||
|
@InjectMetric(GOODGO_WS_CONNECTED_CLIENTS)
|
||||||
|
private readonly wsConnectedClientsGauge: Gauge,
|
||||||
|
@InjectMetric(GOODGO_WS_MESSAGES_TOTAL)
|
||||||
|
private readonly wsMessagesCounter: Counter,
|
||||||
@InjectMetric(WEB_VITALS_LCP)
|
@InjectMetric(WEB_VITALS_LCP)
|
||||||
private readonly lcpHistogram: Histogram,
|
private readonly lcpHistogram: Histogram,
|
||||||
@InjectMetric(WEB_VITALS_FCP)
|
@InjectMetric(WEB_VITALS_FCP)
|
||||||
@@ -81,6 +87,25 @@ export class MetricsService {
|
|||||||
this.httpRequestsCounter.inc(labels);
|
this.httpRequestsCounter.inc(labels);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Track a WebSocket client connection (++) or disconnection (--). */
|
||||||
|
recordWsConnection(namespace: string, delta: 1 | -1): void {
|
||||||
|
this.wsConnectedClientsGauge.inc({ namespace }, delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset the connected-clients gauge for a namespace (e.g. on shutdown). */
|
||||||
|
setWsConnectedClients(namespace: string, count: number): void {
|
||||||
|
this.wsConnectedClientsGauge.set({ namespace }, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record a WebSocket message emitted/received on a given event. */
|
||||||
|
recordWsMessage(
|
||||||
|
namespace: string,
|
||||||
|
event: string,
|
||||||
|
direction: 'in' | 'out',
|
||||||
|
): void {
|
||||||
|
this.wsMessagesCounter.inc({ namespace, event, direction });
|
||||||
|
}
|
||||||
|
|
||||||
/** Map metric name → the correct histogram. */
|
/** Map metric name → the correct histogram. */
|
||||||
private readonly vitalHistograms: Record<string, Histogram | undefined> = {};
|
private readonly vitalHistograms: Record<string, Histogram | undefined> = {};
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export const DB_QUERY_DURATION = 'db_query_duration_seconds';
|
|||||||
export const DB_POOL_ACTIVE_CONNECTIONS = 'db_pool_active_connections';
|
export const DB_POOL_ACTIVE_CONNECTIONS = 'db_pool_active_connections';
|
||||||
export const SEARCH_QUERY_DURATION = 'search_query_duration_seconds';
|
export const SEARCH_QUERY_DURATION = 'search_query_duration_seconds';
|
||||||
|
|
||||||
|
// ── WebSocket Metrics ──
|
||||||
|
export const GOODGO_WS_CONNECTED_CLIENTS = 'goodgo_ws_connected_clients';
|
||||||
|
export const GOODGO_WS_MESSAGES_TOTAL = 'goodgo_ws_messages_total';
|
||||||
|
|
||||||
// ── Web Vitals / RUM Metrics ──
|
// ── Web Vitals / RUM Metrics ──
|
||||||
export const WEB_VITALS_LCP = 'goodgo_web_vitals_lcp_seconds';
|
export const WEB_VITALS_LCP = 'goodgo_web_vitals_lcp_seconds';
|
||||||
export const WEB_VITALS_FCP = 'goodgo_web_vitals_fcp_seconds';
|
export const WEB_VITALS_FCP = 'goodgo_web_vitals_fcp_seconds';
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
DB_QUERY_DURATION,
|
DB_QUERY_DURATION,
|
||||||
DB_POOL_ACTIVE_CONNECTIONS,
|
DB_POOL_ACTIVE_CONNECTIONS,
|
||||||
SEARCH_QUERY_DURATION,
|
SEARCH_QUERY_DURATION,
|
||||||
|
GOODGO_WS_CONNECTED_CLIENTS,
|
||||||
|
GOODGO_WS_MESSAGES_TOTAL,
|
||||||
WEB_VITALS_LCP,
|
WEB_VITALS_LCP,
|
||||||
WEB_VITALS_FCP,
|
WEB_VITALS_FCP,
|
||||||
WEB_VITALS_CLS,
|
WEB_VITALS_CLS,
|
||||||
@@ -83,6 +85,18 @@ import { HttpMetricsInterceptor } from './presentation/interceptors/http-metrics
|
|||||||
labelNames: ['plan'],
|
labelNames: ['plan'],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ── WebSocket Metrics ──
|
||||||
|
makeGaugeProvider({
|
||||||
|
name: GOODGO_WS_CONNECTED_CLIENTS,
|
||||||
|
help: 'Number of active WebSocket clients',
|
||||||
|
labelNames: ['namespace'],
|
||||||
|
}),
|
||||||
|
makeCounterProvider({
|
||||||
|
name: GOODGO_WS_MESSAGES_TOTAL,
|
||||||
|
help: 'Total number of WebSocket messages emitted/received',
|
||||||
|
labelNames: ['namespace', 'event', 'direction'],
|
||||||
|
}),
|
||||||
|
|
||||||
// ── Services & Interceptors ──
|
// ── Services & Interceptors ──
|
||||||
MetricsService,
|
MetricsService,
|
||||||
HttpMetricsInterceptor,
|
HttpMetricsInterceptor,
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { ListingApprovedEvent } from '@modules/admin/domain/events/listing-approved.event';
|
||||||
|
import { InquiryReadEvent } from '@modules/inquiries/domain/events/inquiry-read.event';
|
||||||
|
import { ListingPriceChangedEvent } from '@modules/listings/domain/events/listing-price-changed.event';
|
||||||
|
import {
|
||||||
|
ResidentialInquiryReplyListener,
|
||||||
|
ResidentialNewListingInProjectListener,
|
||||||
|
ResidentialPriceDropListener,
|
||||||
|
} from '../listeners/residential-events.listener';
|
||||||
|
|
||||||
|
function createMockPrisma() {
|
||||||
|
return {
|
||||||
|
listing: { findUnique: vi.fn() },
|
||||||
|
savedSearch: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockGateway() {
|
||||||
|
return {
|
||||||
|
emitResidentialEvent: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockLogger() {
|
||||||
|
return { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ResidentialPriceDropListener', () => {
|
||||||
|
let listener: ResidentialPriceDropListener;
|
||||||
|
let prisma: ReturnType<typeof createMockPrisma>;
|
||||||
|
let gateway: ReturnType<typeof createMockGateway>;
|
||||||
|
let logger: ReturnType<typeof createMockLogger>;
|
||||||
|
|
||||||
|
const listing = {
|
||||||
|
id: 'listing-1',
|
||||||
|
sellerId: 'seller-1',
|
||||||
|
transactionType: 'SALE',
|
||||||
|
priceVND: 2_000_000_000n,
|
||||||
|
property: {
|
||||||
|
title: 'Căn hộ 2PN Quận 7',
|
||||||
|
propertyType: 'APARTMENT',
|
||||||
|
areaM2: 70,
|
||||||
|
bedrooms: 2,
|
||||||
|
district: 'Quận 7',
|
||||||
|
city: 'Hồ Chí Minh',
|
||||||
|
projectDevelopmentId: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = createMockPrisma();
|
||||||
|
gateway = createMockGateway();
|
||||||
|
logger = createMockLogger();
|
||||||
|
listener = new ResidentialPriceDropListener(
|
||||||
|
prisma as any,
|
||||||
|
gateway as any,
|
||||||
|
logger as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits residential:price-drop to each user with a matching saved search', async () => {
|
||||||
|
prisma.listing.findUnique.mockResolvedValue(listing);
|
||||||
|
prisma.savedSearch.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'ss-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
name: 'Quận 7 căn hộ',
|
||||||
|
filters: { city: 'Hồ Chí Minh', district: 'Quận 7', priceMax: 3_000_000_000 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ss-2',
|
||||||
|
userId: 'user-2',
|
||||||
|
name: 'Quận 1',
|
||||||
|
filters: { district: 'Quận 1' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const event = new ListingPriceChangedEvent('listing-1', 2_500_000_000n, 2_000_000_000n);
|
||||||
|
await listener.handle(event);
|
||||||
|
|
||||||
|
expect(gateway.emitResidentialEvent).toHaveBeenCalledTimes(1);
|
||||||
|
expect(gateway.emitResidentialEvent).toHaveBeenCalledWith(
|
||||||
|
'user-1',
|
||||||
|
'residential:price-drop',
|
||||||
|
expect.objectContaining({
|
||||||
|
listingId: 'listing-1',
|
||||||
|
savedSearchId: 'ss-1',
|
||||||
|
oldPrice: '2500000000',
|
||||||
|
newPrice: '2000000000',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not emit when the new price is not lower than the old price', async () => {
|
||||||
|
const event = new ListingPriceChangedEvent('listing-1', 1_000_000_000n, 1_200_000_000n);
|
||||||
|
await listener.handle(event);
|
||||||
|
|
||||||
|
expect(prisma.listing.findUnique).not.toHaveBeenCalled();
|
||||||
|
expect(gateway.emitResidentialEvent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips saved searches owned by the listing seller', async () => {
|
||||||
|
prisma.listing.findUnique.mockResolvedValue(listing);
|
||||||
|
prisma.savedSearch.findMany.mockResolvedValue([
|
||||||
|
{ id: 'ss-self', userId: 'seller-1', name: 'mine', filters: {} },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const event = new ListingPriceChangedEvent('listing-1', 2_500_000_000n, 2_000_000_000n);
|
||||||
|
await listener.handle(event);
|
||||||
|
|
||||||
|
expect(gateway.emitResidentialEvent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swallows infrastructure errors without throwing', async () => {
|
||||||
|
prisma.listing.findUnique.mockRejectedValue(new Error('db down'));
|
||||||
|
|
||||||
|
const event = new ListingPriceChangedEvent('listing-1', 2_000_000_000n, 1_000_000_000n);
|
||||||
|
await expect(listener.handle(event)).resolves.not.toThrow();
|
||||||
|
expect(logger.warn).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ResidentialNewListingInProjectListener', () => {
|
||||||
|
let listener: ResidentialNewListingInProjectListener;
|
||||||
|
let prisma: ReturnType<typeof createMockPrisma>;
|
||||||
|
let gateway: ReturnType<typeof createMockGateway>;
|
||||||
|
let logger: ReturnType<typeof createMockLogger>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = createMockPrisma();
|
||||||
|
gateway = createMockGateway();
|
||||||
|
logger = createMockLogger();
|
||||||
|
listener = new ResidentialNewListingInProjectListener(
|
||||||
|
prisma as any,
|
||||||
|
gateway as any,
|
||||||
|
logger as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits residential:new-listing-in-project to users tracking the project', async () => {
|
||||||
|
prisma.listing.findUnique.mockResolvedValue({
|
||||||
|
id: 'listing-9',
|
||||||
|
sellerId: 'seller-9',
|
||||||
|
priceVND: 3_500_000_000n,
|
||||||
|
property: {
|
||||||
|
title: 'Vinhomes Grand Park S5.02',
|
||||||
|
district: 'Quận 9',
|
||||||
|
city: 'Hồ Chí Minh',
|
||||||
|
projectDevelopmentId: 'project-vgp',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
prisma.savedSearch.findMany.mockResolvedValue([
|
||||||
|
{ id: 'ss-tracker', userId: 'user-10', name: 'VGP', filters: { projectId: 'project-vgp' } },
|
||||||
|
{ id: 'ss-other', userId: 'user-11', name: 'khác', filters: { projectId: 'project-other' } },
|
||||||
|
{ id: 'ss-no-project', userId: 'user-12', name: 'no-project', filters: {} },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const event = new ListingApprovedEvent('listing-9', 'admin-1');
|
||||||
|
await listener.handle(event);
|
||||||
|
|
||||||
|
expect(gateway.emitResidentialEvent).toHaveBeenCalledTimes(1);
|
||||||
|
expect(gateway.emitResidentialEvent).toHaveBeenCalledWith(
|
||||||
|
'user-10',
|
||||||
|
'residential:new-listing-in-project',
|
||||||
|
expect.objectContaining({
|
||||||
|
listingId: 'listing-9',
|
||||||
|
projectId: 'project-vgp',
|
||||||
|
price: '3500000000',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not emit when the listing has no linked project', async () => {
|
||||||
|
prisma.listing.findUnique.mockResolvedValue({
|
||||||
|
id: 'listing-9',
|
||||||
|
sellerId: 'seller-9',
|
||||||
|
priceVND: 1n,
|
||||||
|
property: { title: 't', district: 'd', city: 'c', projectDevelopmentId: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const event = new ListingApprovedEvent('listing-9', 'admin-1');
|
||||||
|
await listener.handle(event);
|
||||||
|
|
||||||
|
expect(prisma.savedSearch.findMany).not.toHaveBeenCalled();
|
||||||
|
expect(gateway.emitResidentialEvent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ResidentialInquiryReplyListener', () => {
|
||||||
|
let listener: ResidentialInquiryReplyListener;
|
||||||
|
let gateway: ReturnType<typeof createMockGateway>;
|
||||||
|
let logger: ReturnType<typeof createMockLogger>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
gateway = createMockGateway();
|
||||||
|
logger = createMockLogger();
|
||||||
|
listener = new ResidentialInquiryReplyListener(gateway as any, logger as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits residential:inquiry-reply to the inquiry author', async () => {
|
||||||
|
const event = new InquiryReadEvent('inq-1', 'listing-1', 'user-author');
|
||||||
|
|
||||||
|
await listener.handle(event);
|
||||||
|
|
||||||
|
expect(gateway.emitResidentialEvent).toHaveBeenCalledWith(
|
||||||
|
'user-author',
|
||||||
|
'residential:inquiry-reply',
|
||||||
|
expect.objectContaining({
|
||||||
|
inquiryId: 'inq-1',
|
||||||
|
listingId: 'listing-1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swallows emission errors without throwing', async () => {
|
||||||
|
gateway.emitResidentialEvent.mockImplementation(() => {
|
||||||
|
throw new Error('server error');
|
||||||
|
});
|
||||||
|
const event = new InquiryReadEvent('inq-2', 'listing-2', 'user-2');
|
||||||
|
|
||||||
|
await expect(listener.handle(event)).resolves.not.toThrow();
|
||||||
|
expect(logger.warn).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { EventsHandler, type IEventHandler } from '@nestjs/cqrs';
|
||||||
|
import { ListingApprovedEvent } from '@modules/admin';
|
||||||
|
import { InquiryReadEvent } from '@modules/inquiries';
|
||||||
|
import { ListingPriceChangedEvent } from '@modules/listings';
|
||||||
|
import { type LoggerService, type PrismaService } from '@modules/shared';
|
||||||
|
import { type NotificationsGateway } from '../../presentation/gateways/notifications.gateway';
|
||||||
|
|
||||||
|
const CONTEXT = 'ResidentialEventsListener';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shape of the `filters` JSON column on `SavedSearch`. Matches fields
|
||||||
|
* consumed by the saved-search alert matcher. Anything else is ignored.
|
||||||
|
*/
|
||||||
|
interface SavedSearchFilters {
|
||||||
|
transactionType?: string;
|
||||||
|
propertyType?: string;
|
||||||
|
projectId?: string;
|
||||||
|
district?: string;
|
||||||
|
city?: string;
|
||||||
|
priceMin?: number;
|
||||||
|
priceMax?: number;
|
||||||
|
areaMin?: number;
|
||||||
|
areaMax?: number;
|
||||||
|
bedrooms?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fans residential domain events out as Socket.IO events on the
|
||||||
|
* `/notifications` namespace so subscribed users get live updates
|
||||||
|
* without waiting for the email/push pipeline.
|
||||||
|
*
|
||||||
|
* Three WS events are emitted:
|
||||||
|
* • `residential:price-drop` — listing price lowered and matches an
|
||||||
|
* alert-enabled saved search.
|
||||||
|
* • `residential:new-listing-in-project` — approved listing lives in
|
||||||
|
* a project that the user tracks via `filters.projectId`.
|
||||||
|
* • `residential:inquiry-reply` — the listing owner/agent marked the
|
||||||
|
* user's inquiry as read, signalling that a reply is incoming.
|
||||||
|
*
|
||||||
|
* Redis pub/sub fan-out is handled by {@link RedisIoAdapter}, so the
|
||||||
|
* broadcast reaches the user's socket regardless of which API pod
|
||||||
|
* holds the connection.
|
||||||
|
*/
|
||||||
|
@EventsHandler(ListingPriceChangedEvent)
|
||||||
|
export class ResidentialPriceDropListener
|
||||||
|
implements IEventHandler<ListingPriceChangedEvent>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly gateway: NotificationsGateway,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handle(event: ListingPriceChangedEvent): Promise<void> {
|
||||||
|
if (event.newPrice >= event.oldPrice) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const listing = await this.prisma.listing.findUnique({
|
||||||
|
where: { id: event.aggregateId },
|
||||||
|
include: { property: true },
|
||||||
|
});
|
||||||
|
if (!listing || !listing.property) return;
|
||||||
|
|
||||||
|
const savedSearches = await this.prisma.savedSearch.findMany({
|
||||||
|
where: { alertEnabled: true },
|
||||||
|
select: { id: true, userId: true, name: true, filters: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let matchCount = 0;
|
||||||
|
for (const search of savedSearches) {
|
||||||
|
if (search.userId === listing.sellerId) continue;
|
||||||
|
|
||||||
|
const filters = normalizeFilters(search.filters);
|
||||||
|
if (!matchesFilters(listing, listing.property, filters)) continue;
|
||||||
|
|
||||||
|
this.gateway.emitResidentialEvent(search.userId, 'residential:price-drop', {
|
||||||
|
listingId: listing.id,
|
||||||
|
savedSearchId: search.id,
|
||||||
|
savedSearchName: search.name,
|
||||||
|
title: listing.property.title,
|
||||||
|
oldPrice: event.oldPrice.toString(),
|
||||||
|
newPrice: event.newPrice.toString(),
|
||||||
|
district: listing.property.district,
|
||||||
|
city: listing.property.city,
|
||||||
|
occurredAt: event.occurredAt.toISOString(),
|
||||||
|
});
|
||||||
|
matchCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchCount > 0) {
|
||||||
|
this.logger.log(
|
||||||
|
`Emitted residential:price-drop to ${matchCount} users for listing ${listing.id}`,
|
||||||
|
CONTEXT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Price-drop WS emission failed for listing ${event.aggregateId}: ${
|
||||||
|
err instanceof Error ? err.message : String(err)
|
||||||
|
}`,
|
||||||
|
CONTEXT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventsHandler(ListingApprovedEvent)
|
||||||
|
export class ResidentialNewListingInProjectListener
|
||||||
|
implements IEventHandler<ListingApprovedEvent>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly gateway: NotificationsGateway,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handle(event: ListingApprovedEvent): Promise<void> {
|
||||||
|
try {
|
||||||
|
const listing = await this.prisma.listing.findUnique({
|
||||||
|
where: { id: event.aggregateId },
|
||||||
|
include: { property: true },
|
||||||
|
});
|
||||||
|
if (!listing || !listing.property?.projectDevelopmentId) return;
|
||||||
|
|
||||||
|
const projectId = listing.property.projectDevelopmentId;
|
||||||
|
|
||||||
|
const savedSearches = await this.prisma.savedSearch.findMany({
|
||||||
|
where: { alertEnabled: true },
|
||||||
|
select: { id: true, userId: true, name: true, filters: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let matchCount = 0;
|
||||||
|
for (const search of savedSearches) {
|
||||||
|
if (search.userId === listing.sellerId) continue;
|
||||||
|
|
||||||
|
const filters = normalizeFilters(search.filters);
|
||||||
|
if (filters.projectId !== projectId) continue;
|
||||||
|
|
||||||
|
this.gateway.emitResidentialEvent(
|
||||||
|
search.userId,
|
||||||
|
'residential:new-listing-in-project',
|
||||||
|
{
|
||||||
|
listingId: listing.id,
|
||||||
|
projectId,
|
||||||
|
savedSearchId: search.id,
|
||||||
|
savedSearchName: search.name,
|
||||||
|
title: listing.property.title,
|
||||||
|
price: listing.priceVND.toString(),
|
||||||
|
district: listing.property.district,
|
||||||
|
city: listing.property.city,
|
||||||
|
occurredAt: event.occurredAt.toISOString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
matchCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchCount > 0) {
|
||||||
|
this.logger.log(
|
||||||
|
`Emitted residential:new-listing-in-project to ${matchCount} users for project ${projectId}`,
|
||||||
|
CONTEXT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`New-listing-in-project WS emission failed for listing ${event.aggregateId}: ${
|
||||||
|
err instanceof Error ? err.message : String(err)
|
||||||
|
}`,
|
||||||
|
CONTEXT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventsHandler(InquiryReadEvent)
|
||||||
|
export class ResidentialInquiryReplyListener
|
||||||
|
implements IEventHandler<InquiryReadEvent>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly gateway: NotificationsGateway,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handle(event: InquiryReadEvent): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.gateway.emitResidentialEvent(event.userId, 'residential:inquiry-reply', {
|
||||||
|
inquiryId: event.aggregateId,
|
||||||
|
listingId: event.listingId,
|
||||||
|
occurredAt: event.occurredAt.toISOString(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Inquiry-reply WS emission failed for inquiry ${event.aggregateId}: ${
|
||||||
|
err instanceof Error ? err.message : String(err)
|
||||||
|
}`,
|
||||||
|
CONTEXT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ────────────────────────────────────────────
|
||||||
|
* Private helpers
|
||||||
|
* ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function normalizeFilters(raw: unknown): SavedSearchFilters {
|
||||||
|
if (!raw || typeof raw !== 'object') return {};
|
||||||
|
return raw as SavedSearchFilters;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesFilters(
|
||||||
|
listing: { transactionType: string; priceVND: bigint; sellerId: string },
|
||||||
|
property: {
|
||||||
|
propertyType: string;
|
||||||
|
areaM2: number;
|
||||||
|
bedrooms: number | null;
|
||||||
|
district: string;
|
||||||
|
city: string;
|
||||||
|
},
|
||||||
|
filters: SavedSearchFilters,
|
||||||
|
): boolean {
|
||||||
|
if (filters.transactionType && filters.transactionType !== listing.transactionType) return false;
|
||||||
|
if (filters.propertyType && filters.propertyType !== property.propertyType) return false;
|
||||||
|
if (filters.district && filters.district !== property.district) return false;
|
||||||
|
if (filters.city && filters.city !== property.city) return false;
|
||||||
|
|
||||||
|
const price = Number(listing.priceVND);
|
||||||
|
if (filters.priceMin !== undefined && price < Number(filters.priceMin)) return false;
|
||||||
|
if (filters.priceMax !== undefined && price > Number(filters.priceMax)) return false;
|
||||||
|
if (filters.areaMin !== undefined && property.areaM2 < Number(filters.areaMin)) return false;
|
||||||
|
if (filters.areaMax !== undefined && property.areaM2 > Number(filters.areaMax)) return false;
|
||||||
|
if (
|
||||||
|
filters.bedrooms !== undefined &&
|
||||||
|
property.bedrooms !== null &&
|
||||||
|
property.bedrooms < Number(filters.bedrooms)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -14,3 +14,9 @@ export {
|
|||||||
NotificationChannel,
|
NotificationChannel,
|
||||||
ALL_CHANNELS,
|
ALL_CHANNELS,
|
||||||
} from './value-objects/notification-channel.vo';
|
} from './value-objects/notification-channel.vo';
|
||||||
|
export {
|
||||||
|
SMS_NOTIFICATION_CHANNEL,
|
||||||
|
type NotificationChannelPort,
|
||||||
|
type SendChannelMessageDto,
|
||||||
|
type SendChannelMessageResult,
|
||||||
|
} from './ports/notification-channel.port';
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { type NotificationChannel } from '../value-objects/notification-channel.vo';
|
||||||
|
|
||||||
|
export interface SendChannelMessageDto {
|
||||||
|
recipient: string;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
templateKey: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendChannelMessageResult {
|
||||||
|
messageId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationChannelPort {
|
||||||
|
readonly channel: NotificationChannel;
|
||||||
|
readonly isAvailable: boolean;
|
||||||
|
send(dto: SendChannelMessageDto): Promise<SendChannelMessageResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SMS_NOTIFICATION_CHANNEL = Symbol('SMS_NOTIFICATION_CHANNEL');
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
SMS_RATE_LIMIT_BUCKETS,
|
||||||
|
SmsRateLimiterService,
|
||||||
|
} from '../services/sms-rate-limiter.service';
|
||||||
|
|
||||||
|
describe('SmsRateLimiterService', () => {
|
||||||
|
let mockRedis: { getClient: ReturnType<typeof vi.fn> };
|
||||||
|
let mockClient: { eval: ReturnType<typeof vi.fn> };
|
||||||
|
let mockLogger: {
|
||||||
|
log: ReturnType<typeof vi.fn>;
|
||||||
|
warn: ReturnType<typeof vi.fn>;
|
||||||
|
error: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let service: SmsRateLimiterService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockClient = { eval: vi.fn() };
|
||||||
|
mockRedis = { getClient: vi.fn().mockReturnValue(mockClient) };
|
||||||
|
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||||
|
service = new SmsRateLimiterService(mockRedis as any, mockLogger as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows the request when Lua script reports under limit', async () => {
|
||||||
|
mockClient.eval.mockResolvedValue([1, 0]);
|
||||||
|
|
||||||
|
const decision = await service.check('+84901234567', 'otp');
|
||||||
|
|
||||||
|
expect(decision.allowed).toBe(true);
|
||||||
|
expect(decision.current).toBe(1);
|
||||||
|
expect(decision.limit).toBe(SMS_RATE_LIMIT_BUCKETS.otp.limit);
|
||||||
|
expect(decision.retryAfterSeconds).toBe(0);
|
||||||
|
expect(decision.bucket).toBe('otp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks the request and returns retryAfter when limit reached', async () => {
|
||||||
|
mockClient.eval.mockResolvedValue([SMS_RATE_LIMIT_BUCKETS.otp.limit, 12_345]);
|
||||||
|
|
||||||
|
const decision = await service.check('+84901234567', 'otp');
|
||||||
|
|
||||||
|
expect(decision.allowed).toBe(false);
|
||||||
|
expect(decision.retryAfterSeconds).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('SMS rate limit hit'),
|
||||||
|
'SmsRateLimiterService',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('namespaces the key per phone and bucket', async () => {
|
||||||
|
mockClient.eval.mockResolvedValue([1, 0]);
|
||||||
|
|
||||||
|
await service.check('+84901234567', 'transactional');
|
||||||
|
|
||||||
|
expect(mockClient.eval).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
1,
|
||||||
|
'sms_rate_limit:transactional:+84901234567',
|
||||||
|
expect.any(Number),
|
||||||
|
SMS_RATE_LIMIT_BUCKETS.transactional.windowSeconds * 1000,
|
||||||
|
SMS_RATE_LIMIT_BUCKETS.transactional.limit,
|
||||||
|
expect.any(String),
|
||||||
|
SMS_RATE_LIMIT_BUCKETS.transactional.windowSeconds,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails open when Redis throws (allows the send, logs warning)', async () => {
|
||||||
|
mockClient.eval.mockRejectedValue(new Error('redis down'));
|
||||||
|
|
||||||
|
const decision = await service.check('+84901234567', 'otpHourly');
|
||||||
|
|
||||||
|
expect(decision.allowed).toBe(true);
|
||||||
|
expect(decision.current).toBe(0);
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Redis error'),
|
||||||
|
'SmsRateLimiterService',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,23 @@
|
|||||||
|
import { HttpStatus } from '@nestjs/common';
|
||||||
|
import { DomainException } from '@modules/shared';
|
||||||
import { StringeeSmsService } from '../services/stringee-sms.service';
|
import { StringeeSmsService } from '../services/stringee-sms.service';
|
||||||
|
|
||||||
|
const allowedDecision = {
|
||||||
|
allowed: true,
|
||||||
|
current: 1,
|
||||||
|
limit: 5,
|
||||||
|
retryAfterSeconds: 0,
|
||||||
|
bucket: 'otp' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const blockedDecision = {
|
||||||
|
allowed: false,
|
||||||
|
current: 5,
|
||||||
|
limit: 5,
|
||||||
|
retryAfterSeconds: 42,
|
||||||
|
bucket: 'otp' as const,
|
||||||
|
};
|
||||||
|
|
||||||
describe('StringeeSmsService', () => {
|
describe('StringeeSmsService', () => {
|
||||||
let service: StringeeSmsService;
|
let service: StringeeSmsService;
|
||||||
let mockLogger: {
|
let mockLogger: {
|
||||||
@@ -7,10 +25,12 @@ describe('StringeeSmsService', () => {
|
|||||||
warn: ReturnType<typeof vi.fn>;
|
warn: ReturnType<typeof vi.fn>;
|
||||||
error: ReturnType<typeof vi.fn>;
|
error: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
|
let mockRateLimiter: { check: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||||
service = new StringeeSmsService(mockLogger as any);
|
mockRateLimiter = { check: vi.fn().mockResolvedValue(allowedDecision) };
|
||||||
|
service = new StringeeSmsService(mockLogger as any, mockRateLimiter as any);
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,6 +76,12 @@ describe('StringeeSmsService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('NotificationChannelPort contract', () => {
|
||||||
|
it('exposes the SMS channel identifier', () => {
|
||||||
|
expect(service.channel).toBe('SMS');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('sendNotification', () => {
|
describe('sendNotification', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env['STRINGEE_API_KEY'] = 'test-api-key';
|
process.env['STRINGEE_API_KEY'] = 'test-api-key';
|
||||||
@@ -183,7 +209,7 @@ describe('StringeeSmsService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('throws when not initialized', async () => {
|
it('throws when not initialized', async () => {
|
||||||
const uninitService = new StringeeSmsService(mockLogger as any);
|
const uninitService = new StringeeSmsService(mockLogger as any, mockRateLimiter as any);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
uninitService.sendNotification({ to: '0901234567', message: 'Test' }),
|
uninitService.sendNotification({ to: '0901234567', message: 'Test' }),
|
||||||
@@ -217,5 +243,117 @@ describe('StringeeSmsService', () => {
|
|||||||
expect(callBody.text).toContain('GoodGo');
|
expect(callBody.text).toContain('GoodGo');
|
||||||
expect(callBody.text).toContain('5 phut');
|
expect(callBody.text).toContain('5 phut');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies the OTP rate-limit bucket before sending', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: true,
|
||||||
|
json: vi.fn().mockResolvedValue({ r: 0, message_id: 'otp-456' }),
|
||||||
|
text: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
|
||||||
|
|
||||||
|
await service.sendOTP({ to: '0901234567', code: '987654' });
|
||||||
|
|
||||||
|
expect(mockRateLimiter.check).toHaveBeenNthCalledWith(1, '+84901234567', 'otp');
|
||||||
|
expect(mockRateLimiter.check).toHaveBeenNthCalledWith(2, '+84901234567', 'otpHourly');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rate limiting', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env['STRINGEE_API_KEY'] = 'test-api-key';
|
||||||
|
service.onModuleInit();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects with TOO_MANY_REQUESTS when per-minute bucket is blocked', async () => {
|
||||||
|
mockRateLimiter.check.mockResolvedValueOnce(blockedDecision);
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.sendOTP({ to: '0901234567', code: '123456' }),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
errorCode: 'TOO_MANY_REQUESTS',
|
||||||
|
status: HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
});
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks hourly bucket when per-minute passes', async () => {
|
||||||
|
mockRateLimiter.check
|
||||||
|
.mockResolvedValueOnce(allowedDecision)
|
||||||
|
.mockResolvedValueOnce({ ...blockedDecision, bucket: 'otpHourly' as const });
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.sendOTP({ to: '0901234567', code: '123456' }),
|
||||||
|
).rejects.toBeInstanceOf(DomainException);
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
expect(mockRateLimiter.check).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses transactional bucket for generic notifications', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: true,
|
||||||
|
json: vi.fn().mockResolvedValue({ r: 0, message_id: 'tx-1' }),
|
||||||
|
text: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
|
||||||
|
|
||||||
|
await service.sendNotification({ to: '0901234567', message: 'Payment confirmed' });
|
||||||
|
|
||||||
|
expect(mockRateLimiter.check).toHaveBeenNthCalledWith(1, '+84901234567', 'transactional');
|
||||||
|
expect(mockRateLimiter.check).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'+84901234567',
|
||||||
|
'transactionalHourly',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('NotificationChannelPort.send', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env['STRINGEE_API_KEY'] = 'test-api-key';
|
||||||
|
service.onModuleInit();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes OTP template keys through the otp bucket', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: true,
|
||||||
|
json: vi.fn().mockResolvedValue({ r: 0, message_id: 'port-otp' }),
|
||||||
|
text: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
|
||||||
|
|
||||||
|
await service.send({
|
||||||
|
recipient: '0901234567',
|
||||||
|
subject: 'OTP',
|
||||||
|
body: '<p>Code 123456</p>',
|
||||||
|
templateKey: 'user.phone_change_otp',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockRateLimiter.check).toHaveBeenNthCalledWith(1, '+84901234567', 'otp');
|
||||||
|
const body = JSON.parse((globalThis.fetch as any).mock.calls[0][1].body);
|
||||||
|
expect(body.text).toBe('Code 123456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips HTML and uses transactional bucket for non-OTP templates', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: true,
|
||||||
|
json: vi.fn().mockResolvedValue({ r: 0, message_id: 'port-tx' }),
|
||||||
|
text: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
|
||||||
|
|
||||||
|
await service.send({
|
||||||
|
recipient: '0901234567',
|
||||||
|
subject: 'Subscription renewed',
|
||||||
|
body: '<p>Your <b>GoodGo</b> plan is active.</p>',
|
||||||
|
templateKey: 'subscription.renewed',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockRateLimiter.check).toHaveBeenNthCalledWith(1, '+84901234567', 'transactional');
|
||||||
|
const body = JSON.parse((globalThis.fetch as any).mock.calls[0][1].body);
|
||||||
|
expect(body.text).toBe('Your GoodGo plan is active.');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ export { PrismaNotificationPreferenceRepository } from './repositories/prisma-no
|
|||||||
export { EmailService, type SendEmailDto } from './services/email.service';
|
export { EmailService, type SendEmailDto } from './services/email.service';
|
||||||
export { FcmService, type SendPushDto } from './services/fcm.service';
|
export { FcmService, type SendPushDto } from './services/fcm.service';
|
||||||
export { StringeeSmsService, type SendSmsDto, type SendOtpDto } from './services/stringee-sms.service';
|
export { StringeeSmsService, type SendSmsDto, type SendOtpDto } from './services/stringee-sms.service';
|
||||||
|
export {
|
||||||
|
SmsRateLimiterService,
|
||||||
|
SMS_RATE_LIMIT_BUCKETS,
|
||||||
|
type SmsRateLimitBucket,
|
||||||
|
type SmsRateLimitDecision,
|
||||||
|
type SmsRateLimitOptions,
|
||||||
|
} from './services/sms-rate-limiter.service';
|
||||||
export { TemplateService, type RenderedTemplate, type TemplateDefinition } from './services/template.service';
|
export { TemplateService, type RenderedTemplate, type TemplateDefinition } from './services/template.service';
|
||||||
export { ZaloOaService, type SendZaloOaDto, type ZaloOaMessageResult } from './services/zalo-oa.service';
|
export { ZaloOaService, type SendZaloOaDto, type ZaloOaMessageResult } from './services/zalo-oa.service';
|
||||||
export { getZaloZnsTemplates, type ZaloZnsTemplateConfig } from './services/zalo-zns-templates';
|
export { getZaloZnsTemplates, type ZaloZnsTemplateConfig } from './services/zalo-zns-templates';
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { type LoggerService, type RedisService } from '@modules/shared';
|
||||||
|
|
||||||
|
export interface SmsRateLimitOptions {
|
||||||
|
limit: number;
|
||||||
|
windowSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmsRateLimitDecision {
|
||||||
|
allowed: boolean;
|
||||||
|
current: number;
|
||||||
|
limit: number;
|
||||||
|
retryAfterSeconds: number;
|
||||||
|
bucket: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SMS_RATE_LIMIT_BUCKETS = {
|
||||||
|
otp: { limit: 5, windowSeconds: 60 } satisfies SmsRateLimitOptions,
|
||||||
|
otpHourly: { limit: 10, windowSeconds: 60 * 60 } satisfies SmsRateLimitOptions,
|
||||||
|
transactional: { limit: 20, windowSeconds: 60 } satisfies SmsRateLimitOptions,
|
||||||
|
transactionalHourly: { limit: 100, windowSeconds: 60 * 60 } satisfies SmsRateLimitOptions,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type SmsRateLimitBucket = keyof typeof SMS_RATE_LIMIT_BUCKETS;
|
||||||
|
|
||||||
|
const SLIDING_WINDOW_LUA = `
|
||||||
|
local key = KEYS[1]
|
||||||
|
local now = tonumber(ARGV[1])
|
||||||
|
local windowMs = tonumber(ARGV[2])
|
||||||
|
local limit = tonumber(ARGV[3])
|
||||||
|
local requestId = ARGV[4]
|
||||||
|
local windowSec = tonumber(ARGV[5])
|
||||||
|
|
||||||
|
redis.call('ZREMRANGEBYSCORE', key, 0, now - windowMs)
|
||||||
|
local current = redis.call('ZCARD', key)
|
||||||
|
|
||||||
|
if current < limit then
|
||||||
|
redis.call('ZADD', key, now, requestId)
|
||||||
|
redis.call('EXPIRE', key, windowSec + 1)
|
||||||
|
return {current + 1, 0}
|
||||||
|
else
|
||||||
|
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
|
||||||
|
local retryAfterMs = 0
|
||||||
|
if #oldest >= 2 then
|
||||||
|
retryAfterMs = tonumber(oldest[2]) + windowMs - now
|
||||||
|
if retryAfterMs < 0 then retryAfterMs = 0 end
|
||||||
|
end
|
||||||
|
return {current, retryAfterMs}
|
||||||
|
end
|
||||||
|
`;
|
||||||
|
|
||||||
|
let requestCounter = 0;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SmsRateLimiterService {
|
||||||
|
constructor(
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async check(phone: string, bucket: SmsRateLimitBucket): Promise<SmsRateLimitDecision> {
|
||||||
|
const options = SMS_RATE_LIMIT_BUCKETS[bucket];
|
||||||
|
const key = `sms_rate_limit:${bucket}:${phone}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = this.redis.getClient();
|
||||||
|
const now = Date.now();
|
||||||
|
const requestId = `${now}:${process.pid}:${++requestCounter}`;
|
||||||
|
|
||||||
|
const result = (await client.eval(
|
||||||
|
SLIDING_WINDOW_LUA,
|
||||||
|
1,
|
||||||
|
key,
|
||||||
|
now,
|
||||||
|
options.windowSeconds * 1000,
|
||||||
|
options.limit,
|
||||||
|
requestId,
|
||||||
|
options.windowSeconds,
|
||||||
|
)) as [number, number];
|
||||||
|
|
||||||
|
const current = result[0];
|
||||||
|
const retryAfterMs = result[1];
|
||||||
|
const allowed = retryAfterMs === 0 && current <= options.limit;
|
||||||
|
const retryAfterSeconds = allowed ? 0 : Math.max(1, Math.ceil(retryAfterMs / 1000));
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
this.logger.warn(
|
||||||
|
`SMS rate limit hit for ${this.maskPhone(phone)} bucket=${bucket} ` +
|
||||||
|
`current=${current}/${options.limit} retryAfter=${retryAfterSeconds}s`,
|
||||||
|
'SmsRateLimiterService',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed,
|
||||||
|
current,
|
||||||
|
limit: options.limit,
|
||||||
|
retryAfterSeconds,
|
||||||
|
bucket,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`SMS rate limit check failed (Redis error), failing open for ${this.maskPhone(phone)}: ` +
|
||||||
|
`${error instanceof Error ? error.message : 'unknown'}`,
|
||||||
|
'SmsRateLimiterService',
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
current: 0,
|
||||||
|
limit: options.limit,
|
||||||
|
retryAfterSeconds: 0,
|
||||||
|
bucket,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private maskPhone(phone: string): string {
|
||||||
|
if (phone.length <= 4) return '***';
|
||||||
|
return `${phone.slice(0, 3)}***${phone.slice(-2)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,21 @@
|
|||||||
import { Injectable, type OnModuleInit } from '@nestjs/common';
|
import { HttpStatus, Injectable, type OnModuleInit } from '@nestjs/common';
|
||||||
import { type LoggerService } from '@modules/shared';
|
import { DomainException, ErrorCode, type LoggerService } from '@modules/shared';
|
||||||
|
import type {
|
||||||
|
NotificationChannelPort,
|
||||||
|
SendChannelMessageDto,
|
||||||
|
SendChannelMessageResult,
|
||||||
|
} from '../../domain/ports/notification-channel.port';
|
||||||
|
import { type NotificationChannel } from '../../domain/value-objects/notification-channel.vo';
|
||||||
|
import {
|
||||||
|
type SmsRateLimitBucket,
|
||||||
|
type SmsRateLimiterService,
|
||||||
|
} from './sms-rate-limiter.service';
|
||||||
|
|
||||||
export interface SendSmsDto {
|
export interface SendSmsDto {
|
||||||
to: string;
|
to: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
/** Rate-limit bucket; defaults to `transactional`. OTP flows should pass `otp`. */
|
||||||
|
bucket?: SmsRateLimitBucket;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendOtpDto {
|
export interface SendOtpDto {
|
||||||
@@ -13,15 +25,26 @@ export interface SendOtpDto {
|
|||||||
|
|
||||||
const MAX_RETRIES = 3;
|
const MAX_RETRIES = 3;
|
||||||
const BASE_DELAY_MS = 1000;
|
const BASE_DELAY_MS = 1000;
|
||||||
|
const OTP_TEMPLATE_KEYS = new Set([
|
||||||
|
'user.phone_change_otp',
|
||||||
|
'auth.login_otp',
|
||||||
|
'auth.kyc_otp',
|
||||||
|
'auth.phone_verify_otp',
|
||||||
|
]);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StringeeSmsService implements OnModuleInit {
|
export class StringeeSmsService implements OnModuleInit, NotificationChannelPort {
|
||||||
|
readonly channel: NotificationChannel = 'SMS';
|
||||||
|
|
||||||
private apiKey = '';
|
private apiKey = '';
|
||||||
private brandName = '';
|
private brandName = '';
|
||||||
private initialized = false;
|
private initialized = false;
|
||||||
private readonly baseUrl = 'https://api.stringee.com/v1/sms';
|
private readonly baseUrl = 'https://api.stringee.com/v1/sms';
|
||||||
|
|
||||||
constructor(private readonly logger: LoggerService) {}
|
constructor(
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
private readonly rateLimiter: SmsRateLimiterService,
|
||||||
|
) {}
|
||||||
|
|
||||||
onModuleInit(): void {
|
onModuleInit(): void {
|
||||||
this.apiKey = process.env['STRINGEE_API_KEY'] ?? '';
|
this.apiKey = process.env['STRINGEE_API_KEY'] ?? '';
|
||||||
@@ -46,26 +69,63 @@ export class StringeeSmsService implements OnModuleInit {
|
|||||||
return this.initialized;
|
return this.initialized;
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendOTP(dto: SendOtpDto): Promise<{ messageId: string }> {
|
async sendOTP(dto: SendOtpDto): Promise<SendChannelMessageResult> {
|
||||||
const message = `[${this.brandName}] Ma xac thuc cua ban la: ${dto.code}. Ma co hieu luc trong 5 phut.`;
|
const message = `[${this.brandName}] Ma xac thuc cua ban la: ${dto.code}. Ma co hieu luc trong 5 phut.`;
|
||||||
return this.sendWithRetry({ to: dto.to, message });
|
return this.dispatch({ to: dto.to, message, bucket: 'otp' });
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendNotification(dto: SendSmsDto): Promise<{ messageId: string }> {
|
async sendNotification(dto: SendSmsDto): Promise<SendChannelMessageResult> {
|
||||||
return this.sendWithRetry(dto);
|
return this.dispatch(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendWithRetry(dto: SendSmsDto): Promise<{ messageId: string }> {
|
async send(dto: SendChannelMessageDto): Promise<SendChannelMessageResult> {
|
||||||
|
const bucket: SmsRateLimitBucket = OTP_TEMPLATE_KEYS.has(dto.templateKey) ? 'otp' : 'transactional';
|
||||||
|
const plainText = this.stripHtml(dto.body);
|
||||||
|
return this.dispatch({ to: dto.recipient, message: plainText, bucket });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async dispatch(dto: SendSmsDto): Promise<SendChannelMessageResult> {
|
||||||
if (!this.initialized) {
|
if (!this.initialized) {
|
||||||
throw new Error('Stringee SMS not initialized — STRINGEE_API_KEY not configured');
|
throw new Error('Stringee SMS not initialized — STRINGEE_API_KEY not configured');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const phone = this.normalizePhone(dto.to);
|
||||||
|
const bucket: SmsRateLimitBucket = dto.bucket ?? 'transactional';
|
||||||
|
|
||||||
|
await this.enforceRateLimit(phone, bucket);
|
||||||
|
|
||||||
|
return this.sendWithRetry(phone, dto.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enforceRateLimit(phone: string, bucket: SmsRateLimitBucket): Promise<void> {
|
||||||
|
const perMinute = await this.rateLimiter.check(phone, bucket);
|
||||||
|
if (!perMinute.allowed) {
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.TOO_MANY_REQUESTS,
|
||||||
|
`SMS rate limit exceeded. Retry after ${perMinute.retryAfterSeconds}s.`,
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
{ bucket: perMinute.bucket, retryAfterSeconds: perMinute.retryAfterSeconds },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hourlyBucket: SmsRateLimitBucket = bucket === 'otp' ? 'otpHourly' : 'transactionalHourly';
|
||||||
|
const perHour = await this.rateLimiter.check(phone, hourlyBucket);
|
||||||
|
if (!perHour.allowed) {
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.TOO_MANY_REQUESTS,
|
||||||
|
`Hourly SMS limit exceeded. Retry after ${perHour.retryAfterSeconds}s.`,
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
{ bucket: perHour.bucket, retryAfterSeconds: perHour.retryAfterSeconds },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendWithRetry(phone: string, message: string): Promise<SendChannelMessageResult> {
|
||||||
let lastError: Error | undefined;
|
let lastError: Error | undefined;
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||||
try {
|
try {
|
||||||
const result = await this.send(dto);
|
return await this.postToStringee(phone, message);
|
||||||
return result;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error instanceof Error ? error : new Error(String(error));
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
|
||||||
@@ -87,13 +147,11 @@ export class StringeeSmsService implements OnModuleInit {
|
|||||||
throw lastError;
|
throw lastError;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async send(dto: SendSmsDto): Promise<{ messageId: string }> {
|
private async postToStringee(phone: string, message: string): Promise<SendChannelMessageResult> {
|
||||||
const phone = this.normalizePhone(dto.to);
|
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
from: { type: 'sms', number: this.brandName, alias: this.brandName },
|
from: { type: 'sms', number: this.brandName, alias: this.brandName },
|
||||||
to: [{ type: 'sms', number: phone }],
|
to: [{ type: 'sms', number: phone }],
|
||||||
text: dto.message,
|
text: message,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch(this.baseUrl, {
|
const response = await fetch(this.baseUrl, {
|
||||||
@@ -112,7 +170,6 @@ export class StringeeSmsService implements OnModuleInit {
|
|||||||
|
|
||||||
const data = (await response.json()) as { message_id?: string; r?: number; message?: string };
|
const data = (await response.json()) as { message_id?: string; r?: number; message?: string };
|
||||||
|
|
||||||
// Stringee returns r=0 on success
|
|
||||||
if (data.r !== undefined && data.r !== 0) {
|
if (data.r !== undefined && data.r !== 0) {
|
||||||
throw new Error(`Stringee SMS rejected (code ${data.r}): ${data.message ?? 'Unknown reason'}`);
|
throw new Error(`Stringee SMS rejected (code ${data.r}): ${data.message ?? 'Unknown reason'}`);
|
||||||
}
|
}
|
||||||
@@ -127,10 +184,6 @@ export class StringeeSmsService implements OnModuleInit {
|
|||||||
return { messageId };
|
return { messageId };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize VN phone numbers to E.164 format (+84...).
|
|
||||||
* Accepts: 0901234567, +84901234567, 84901234567
|
|
||||||
*/
|
|
||||||
private normalizePhone(phone: string): string {
|
private normalizePhone(phone: string): string {
|
||||||
const cleaned = phone.replace(/[\s\-()]/g, '');
|
const cleaned = phone.replace(/[\s\-()]/g, '');
|
||||||
|
|
||||||
@@ -146,6 +199,10 @@ export class StringeeSmsService implements OnModuleInit {
|
|||||||
return cleaned;
|
return cleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private stripHtml(html: string): string {
|
||||||
|
return html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
private delay(ms: number): Promise<void> {
|
private delay(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { CqrsModule } from '@nestjs/cqrs';
|
import { CqrsModule } from '@nestjs/cqrs';
|
||||||
import { AuthModule } from '@modules/auth';
|
import { AuthModule } from '@modules/auth';
|
||||||
|
import { MetricsModule } from '@modules/metrics';
|
||||||
import { SendNotificationHandler } from './application/commands/send-notification/send-notification.handler';
|
import { SendNotificationHandler } from './application/commands/send-notification/send-notification.handler';
|
||||||
import { AgentVerifiedListener } from './application/listeners/agent-verified.listener';
|
import { AgentVerifiedListener } from './application/listeners/agent-verified.listener';
|
||||||
import { EmailChangeRequestedListener } from './application/listeners/email-change-requested.listener';
|
import { EmailChangeRequestedListener } from './application/listeners/email-change-requested.listener';
|
||||||
@@ -13,17 +14,24 @@ import { PaymentFailedListener } from './application/listeners/payment-failed.li
|
|||||||
import { PaymentRefundedListener } from './application/listeners/payment-refunded.listener';
|
import { PaymentRefundedListener } from './application/listeners/payment-refunded.listener';
|
||||||
import { PhoneChangeRequestedListener } from './application/listeners/phone-change-requested.listener';
|
import { PhoneChangeRequestedListener } from './application/listeners/phone-change-requested.listener';
|
||||||
import { QuotaExceededListener } from './application/listeners/quota-exceeded.listener';
|
import { QuotaExceededListener } from './application/listeners/quota-exceeded.listener';
|
||||||
|
import {
|
||||||
|
ResidentialInquiryReplyListener,
|
||||||
|
ResidentialNewListingInProjectListener,
|
||||||
|
ResidentialPriceDropListener,
|
||||||
|
} from './application/listeners/residential-events.listener';
|
||||||
import { SubscriptionExpiredListener } from './application/listeners/subscription-expired.listener';
|
import { SubscriptionExpiredListener } from './application/listeners/subscription-expired.listener';
|
||||||
import { SubscriptionExpiringListener } from './application/listeners/subscription-expiring.listener';
|
import { SubscriptionExpiringListener } from './application/listeners/subscription-expiring.listener';
|
||||||
import { SubscriptionRenewedListener } from './application/listeners/subscription-renewed.listener';
|
import { SubscriptionRenewedListener } from './application/listeners/subscription-renewed.listener';
|
||||||
import { UserKycUpdatedListener } from './application/listeners/user-kyc-updated.listener';
|
import { UserKycUpdatedListener } from './application/listeners/user-kyc-updated.listener';
|
||||||
import { UserRegisteredListener } from './application/listeners/user-registered.listener';
|
import { UserRegisteredListener } from './application/listeners/user-registered.listener';
|
||||||
|
import { SMS_NOTIFICATION_CHANNEL } from './domain/ports/notification-channel.port';
|
||||||
import { NOTIFICATION_PREFERENCE_REPOSITORY } from './domain/repositories/notification-preference.repository';
|
import { NOTIFICATION_PREFERENCE_REPOSITORY } from './domain/repositories/notification-preference.repository';
|
||||||
import { NOTIFICATION_REPOSITORY } from './domain/repositories/notification.repository';
|
import { NOTIFICATION_REPOSITORY } from './domain/repositories/notification.repository';
|
||||||
import { PrismaNotificationPreferenceRepository } from './infrastructure/repositories/prisma-notification-preference.repository';
|
import { PrismaNotificationPreferenceRepository } from './infrastructure/repositories/prisma-notification-preference.repository';
|
||||||
import { PrismaNotificationRepository } from './infrastructure/repositories/prisma-notification.repository';
|
import { PrismaNotificationRepository } from './infrastructure/repositories/prisma-notification.repository';
|
||||||
import { EmailService } from './infrastructure/services/email.service';
|
import { EmailService } from './infrastructure/services/email.service';
|
||||||
import { FcmService } from './infrastructure/services/fcm.service';
|
import { FcmService } from './infrastructure/services/fcm.service';
|
||||||
|
import { SmsRateLimiterService } from './infrastructure/services/sms-rate-limiter.service';
|
||||||
import { StringeeSmsService } from './infrastructure/services/stringee-sms.service';
|
import { StringeeSmsService } from './infrastructure/services/stringee-sms.service';
|
||||||
import { TemplateService } from './infrastructure/services/template.service';
|
import { TemplateService } from './infrastructure/services/template.service';
|
||||||
import { ZaloOaService } from './infrastructure/services/zalo-oa.service';
|
import { ZaloOaService } from './infrastructure/services/zalo-oa.service';
|
||||||
@@ -50,10 +58,13 @@ const EventListeners = [
|
|||||||
UserKycUpdatedListener,
|
UserKycUpdatedListener,
|
||||||
EmailChangeRequestedListener,
|
EmailChangeRequestedListener,
|
||||||
PhoneChangeRequestedListener,
|
PhoneChangeRequestedListener,
|
||||||
|
ResidentialPriceDropListener,
|
||||||
|
ResidentialNewListingInProjectListener,
|
||||||
|
ResidentialInquiryReplyListener,
|
||||||
];
|
];
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CqrsModule, AuthModule],
|
imports: [CqrsModule, AuthModule, MetricsModule],
|
||||||
controllers: [NotificationsController, ZaloOaWebhookController],
|
controllers: [NotificationsController, ZaloOaWebhookController],
|
||||||
providers: [
|
providers: [
|
||||||
// Repositories
|
// Repositories
|
||||||
@@ -63,7 +74,9 @@ const EventListeners = [
|
|||||||
// Services
|
// Services
|
||||||
EmailService,
|
EmailService,
|
||||||
FcmService,
|
FcmService,
|
||||||
|
SmsRateLimiterService,
|
||||||
StringeeSmsService,
|
StringeeSmsService,
|
||||||
|
{ provide: SMS_NOTIFICATION_CHANNEL, useExisting: StringeeSmsService },
|
||||||
ZaloOaService,
|
ZaloOaService,
|
||||||
TemplateService,
|
TemplateService,
|
||||||
|
|
||||||
@@ -76,6 +89,15 @@ const EventListeners = [
|
|||||||
// Event Listeners
|
// Event Listeners
|
||||||
...EventListeners,
|
...EventListeners,
|
||||||
],
|
],
|
||||||
exports: [EmailService, FcmService, StringeeSmsService, ZaloOaService, TemplateService, NotificationsGateway],
|
exports: [
|
||||||
|
EmailService,
|
||||||
|
FcmService,
|
||||||
|
SmsRateLimiterService,
|
||||||
|
StringeeSmsService,
|
||||||
|
SMS_NOTIFICATION_CHANNEL,
|
||||||
|
ZaloOaService,
|
||||||
|
TemplateService,
|
||||||
|
NotificationsGateway,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class NotificationsModule {}
|
export class NotificationsModule {}
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ describe('NotificationsGateway', () => {
|
|||||||
getClient: ReturnType<typeof vi.fn>;
|
getClient: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
let mockNotificationRepo: { countUnreadByUserId: ReturnType<typeof vi.fn> };
|
let mockNotificationRepo: { countUnreadByUserId: ReturnType<typeof vi.fn> };
|
||||||
|
let mockMetrics: {
|
||||||
|
recordWsConnection: ReturnType<typeof vi.fn>;
|
||||||
|
setWsConnectedClients: ReturnType<typeof vi.fn>;
|
||||||
|
recordWsMessage: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
let mockServer: {
|
let mockServer: {
|
||||||
to: ReturnType<typeof vi.fn>;
|
to: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
@@ -53,11 +58,17 @@ describe('NotificationsGateway', () => {
|
|||||||
getClient: vi.fn().mockReturnValue({ exists: vi.fn().mockResolvedValue(0), incr: vi.fn() }),
|
getClient: vi.fn().mockReturnValue({ exists: vi.fn().mockResolvedValue(0), incr: vi.fn() }),
|
||||||
};
|
};
|
||||||
mockNotificationRepo = { countUnreadByUserId: vi.fn().mockResolvedValue(3) };
|
mockNotificationRepo = { countUnreadByUserId: vi.fn().mockResolvedValue(3) };
|
||||||
|
mockMetrics = {
|
||||||
|
recordWsConnection: vi.fn(),
|
||||||
|
setWsConnectedClients: vi.fn(),
|
||||||
|
recordWsMessage: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
gateway = new NotificationsGateway(
|
gateway = new NotificationsGateway(
|
||||||
mockTokenService as any,
|
mockTokenService as any,
|
||||||
mockLogger as any,
|
mockLogger as any,
|
||||||
mockRedisService as any,
|
mockRedisService as any,
|
||||||
|
mockMetrics as any,
|
||||||
mockNotificationRepo as any,
|
mockNotificationRepo as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -74,6 +85,14 @@ describe('NotificationsGateway', () => {
|
|||||||
'NotificationsGateway',
|
'NotificationsGateway',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resets the WS connected-clients gauge to 0', () => {
|
||||||
|
gateway.afterInit();
|
||||||
|
expect(mockMetrics.setWsConnectedClients).toHaveBeenCalledWith(
|
||||||
|
'/notifications',
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleConnection', () => {
|
describe('handleConnection', () => {
|
||||||
@@ -152,6 +171,28 @@ describe('NotificationsGateway', () => {
|
|||||||
expect(mockNotificationRepo.countUnreadByUserId).toHaveBeenCalledWith('user-1');
|
expect(mockNotificationRepo.countUnreadByUserId).toHaveBeenCalledWith('user-1');
|
||||||
expect(socket.emit).toHaveBeenCalledWith('notification:unread-count', { unreadCount: 3 });
|
expect(socket.emit).toHaveBeenCalledWith('notification:unread-count', { unreadCount: 3 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('increments WS connection metric and records the initial unread-count emit', async () => {
|
||||||
|
const socket = createMockSocket();
|
||||||
|
|
||||||
|
await gateway.handleConnection(socket);
|
||||||
|
|
||||||
|
expect(mockMetrics.recordWsConnection).toHaveBeenCalledWith('/notifications', 1);
|
||||||
|
expect(mockMetrics.recordWsMessage).toHaveBeenCalledWith(
|
||||||
|
'/notifications',
|
||||||
|
'notification:unread-count',
|
||||||
|
'out',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not increment metrics when auth fails', async () => {
|
||||||
|
mockTokenService.verifyAccessToken.mockReturnValue(null);
|
||||||
|
const socket = createMockSocket();
|
||||||
|
|
||||||
|
await gateway.handleConnection(socket);
|
||||||
|
|
||||||
|
expect(mockMetrics.recordWsConnection).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleDisconnect', () => {
|
describe('handleDisconnect', () => {
|
||||||
@@ -183,6 +224,24 @@ describe('NotificationsGateway', () => {
|
|||||||
// No prior connection — should not throw
|
// No prior connection — should not throw
|
||||||
expect(() => gateway.handleDisconnect(socket)).not.toThrow();
|
expect(() => gateway.handleDisconnect(socket)).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('decrements the WS connection metric when a tracked socket disconnects', async () => {
|
||||||
|
const socket = createMockSocket({ id: 'sock-1' });
|
||||||
|
await gateway.handleConnection(socket);
|
||||||
|
mockMetrics.recordWsConnection.mockClear();
|
||||||
|
|
||||||
|
gateway.handleDisconnect(socket);
|
||||||
|
|
||||||
|
expect(mockMetrics.recordWsConnection).toHaveBeenCalledWith('/notifications', -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not decrement the gauge for untracked sockets', () => {
|
||||||
|
const socket = createMockSocket();
|
||||||
|
|
||||||
|
gateway.handleDisconnect(socket);
|
||||||
|
|
||||||
|
expect(mockMetrics.recordWsConnection).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleNotificationSent', () => {
|
describe('handleNotificationSent', () => {
|
||||||
@@ -273,4 +332,25 @@ describe('NotificationsGateway', () => {
|
|||||||
expect(mockRedisService.del).not.toHaveBeenCalled();
|
expect(mockRedisService.del).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('emitResidentialEvent', () => {
|
||||||
|
it('emits the residential event to the user room and records a ws metric', () => {
|
||||||
|
const roomEmit = vi.fn();
|
||||||
|
mockServer.to.mockReturnValue({ emit: roomEmit });
|
||||||
|
|
||||||
|
gateway.emitResidentialEvent('user-42', 'residential:price-drop', {
|
||||||
|
listingId: 'listing-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockServer.to).toHaveBeenCalledWith('user:user-42');
|
||||||
|
expect(roomEmit).toHaveBeenCalledWith('residential:price-drop', {
|
||||||
|
listingId: 'listing-1',
|
||||||
|
});
|
||||||
|
expect(mockMetrics.recordWsMessage).toHaveBeenCalledWith(
|
||||||
|
'/notifications',
|
||||||
|
'residential:price-drop',
|
||||||
|
'out',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type { Server, Socket } from 'socket.io';
|
|||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports
|
||||||
import { TokenService, type JwtPayload } from '@modules/auth';
|
import { TokenService, type JwtPayload } from '@modules/auth';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports
|
||||||
|
import { MetricsService } from '@modules/metrics';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports
|
||||||
import { LoggerService, RedisService } from '@modules/shared';
|
import { LoggerService, RedisService } from '@modules/shared';
|
||||||
import type { NotificationSentEvent } from '../../domain/events/notification-sent.event';
|
import type { NotificationSentEvent } from '../../domain/events/notification-sent.event';
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +26,20 @@ const UNREAD_COUNT_KEY = (userId: string) => `notifications:unread:${userId}`;
|
|||||||
/** TTL for the cached unread count (1 hour). */
|
/** TTL for the cached unread count (1 hour). */
|
||||||
const UNREAD_COUNT_TTL = 3600;
|
const UNREAD_COUNT_TTL = 3600;
|
||||||
|
|
||||||
|
/** Namespace label used for Prometheus metrics. */
|
||||||
|
const NAMESPACE_LABEL = '/notifications';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server → client heartbeat every 25 s and 20 s wait for the pong
|
||||||
|
* before declaring the connection dead. Matches socket.io defaults but
|
||||||
|
* pinned explicitly so operations teams can tune via env without code
|
||||||
|
* changes. Clients must reconnect with exponential backoff on their side.
|
||||||
|
*/
|
||||||
|
const WS_PING_INTERVAL_MS = Number(process.env['WS_PING_INTERVAL_MS'] ?? 25_000);
|
||||||
|
const WS_PING_TIMEOUT_MS = Number(process.env['WS_PING_TIMEOUT_MS'] ?? 20_000);
|
||||||
|
/** Allow large upgrade windows so poor networks don't churn handshakes. */
|
||||||
|
const WS_CONNECT_TIMEOUT_MS = Number(process.env['WS_CONNECT_TIMEOUT_MS'] ?? 45_000);
|
||||||
|
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
namespace: '/notifications',
|
namespace: '/notifications',
|
||||||
cors: {
|
cors: {
|
||||||
@@ -32,6 +48,10 @@ const UNREAD_COUNT_TTL = 3600;
|
|||||||
.map((o) => o.trim()),
|
.map((o) => o.trim()),
|
||||||
credentials: true,
|
credentials: true,
|
||||||
},
|
},
|
||||||
|
pingInterval: WS_PING_INTERVAL_MS,
|
||||||
|
pingTimeout: WS_PING_TIMEOUT_MS,
|
||||||
|
connectTimeout: WS_CONNECT_TIMEOUT_MS,
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
})
|
})
|
||||||
export class NotificationsGateway
|
export class NotificationsGateway
|
||||||
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
|
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
|
||||||
@@ -46,12 +66,17 @@ export class NotificationsGateway
|
|||||||
private readonly tokenService: TokenService,
|
private readonly tokenService: TokenService,
|
||||||
private readonly logger: LoggerService,
|
private readonly logger: LoggerService,
|
||||||
private readonly redisService: RedisService,
|
private readonly redisService: RedisService,
|
||||||
|
private readonly metrics: MetricsService,
|
||||||
@Inject(NOTIFICATION_REPOSITORY)
|
@Inject(NOTIFICATION_REPOSITORY)
|
||||||
private readonly notificationRepo: INotificationRepository,
|
private readonly notificationRepo: INotificationRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
afterInit(): void {
|
afterInit(): void {
|
||||||
this.logger.log('NotificationsGateway initialized', 'NotificationsGateway');
|
this.metrics.setWsConnectedClients(NAMESPACE_LABEL, 0);
|
||||||
|
this.logger.log(
|
||||||
|
`NotificationsGateway initialized (pingInterval=${WS_PING_INTERVAL_MS}ms, pingTimeout=${WS_PING_TIMEOUT_MS}ms)`,
|
||||||
|
'NotificationsGateway',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ────────────────────────────────────────────
|
/* ────────────────────────────────────────────
|
||||||
@@ -83,6 +108,13 @@ export class NotificationsGateway
|
|||||||
const unreadCount = await this.getUnreadCount(payload.sub);
|
const unreadCount = await this.getUnreadCount(payload.sub);
|
||||||
client.emit('notification:unread-count', { unreadCount });
|
client.emit('notification:unread-count', { unreadCount });
|
||||||
|
|
||||||
|
this.metrics.recordWsConnection(NAMESPACE_LABEL, 1);
|
||||||
|
this.metrics.recordWsMessage(
|
||||||
|
NAMESPACE_LABEL,
|
||||||
|
'notification:unread-count',
|
||||||
|
'out',
|
||||||
|
);
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`WS connected: user=${payload.sub} socket=${client.id}`,
|
`WS connected: user=${payload.sub} socket=${client.id}`,
|
||||||
'NotificationsGateway',
|
'NotificationsGateway',
|
||||||
@@ -107,6 +139,8 @@ export class NotificationsGateway
|
|||||||
this.userSockets.delete(userId);
|
this.userSockets.delete(userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Only decrement if the socket completed auth (we tracked it).
|
||||||
|
this.metrics.recordWsConnection(NAMESPACE_LABEL, -1);
|
||||||
}
|
}
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`WS disconnected: user=${userId ?? 'unknown'} socket=${client.id}`,
|
`WS disconnected: user=${userId ?? 'unknown'} socket=${client.id}`,
|
||||||
@@ -178,6 +212,23 @@ export class NotificationsGateway
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a residential WS event (price drop, new listing in subscribed
|
||||||
|
* project, inquiry reply) to a single user's private room.
|
||||||
|
*
|
||||||
|
* The Redis pub/sub adapter fans the broadcast out to every API
|
||||||
|
* instance, so the target user receives the payload regardless of
|
||||||
|
* which node their socket is attached to.
|
||||||
|
*/
|
||||||
|
emitResidentialEvent(
|
||||||
|
userId: string,
|
||||||
|
event: 'residential:price-drop' | 'residential:new-listing-in-project' | 'residential:inquiry-reply',
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
this.server.to(`user:${userId}`).emit(event, payload);
|
||||||
|
this.metrics.recordWsMessage(NAMESPACE_LABEL, event, 'out');
|
||||||
|
}
|
||||||
|
|
||||||
/* ────────────────────────────────────────────
|
/* ────────────────────────────────────────────
|
||||||
* Private helpers
|
* Private helpers
|
||||||
* ──────────────────────────────────────────── */
|
* ──────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { type PaymentType } from '@prisma/client';
|
||||||
|
import { type DomainEvent } from '@modules/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emitted when an admin manually confirms a VN bank transfer payment.
|
||||||
|
*
|
||||||
|
* Carries enough metadata for downstream consumers (audit logging,
|
||||||
|
* subscription activation, accounting) without requiring a re-read
|
||||||
|
* of the payment aggregate.
|
||||||
|
*/
|
||||||
|
export class BankTransferConfirmedEvent implements DomainEvent {
|
||||||
|
readonly eventName = 'payment.bank_transfer_confirmed';
|
||||||
|
readonly occurredAt = new Date();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly aggregateId: string,
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly type: PaymentType,
|
||||||
|
public readonly amountVND: bigint,
|
||||||
|
public readonly confirmedBy: string,
|
||||||
|
public readonly bankReference: string | null,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { ConfirmBankTransferCommand } from '../../../application/commands/confirm-bank-transfer/confirm-bank-transfer.command';
|
||||||
|
import { AdminPaymentsController } from '../admin-payments.controller';
|
||||||
|
|
||||||
|
describe('AdminPaymentsController', () => {
|
||||||
|
let controller: AdminPaymentsController;
|
||||||
|
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
const mockAdmin = { sub: 'admin-1', phone: '0901234567', role: 'ADMIN' } as any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockCommandBus = { execute: vi.fn() };
|
||||||
|
controller = new AdminPaymentsController(mockCommandBus as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /admin/payments/:id/confirm-transfer', () => {
|
||||||
|
it('dispatches ConfirmBankTransferCommand with admin sub + bankReference', async () => {
|
||||||
|
const expected = {
|
||||||
|
paymentId: 'pay-1',
|
||||||
|
status: 'COMPLETED',
|
||||||
|
confirmedBy: 'admin-1',
|
||||||
|
};
|
||||||
|
mockCommandBus.execute.mockResolvedValue(expected);
|
||||||
|
|
||||||
|
const result = await controller.confirmBankTransfer(
|
||||||
|
'pay-1',
|
||||||
|
{ bankReference: 'FT123456' } as any,
|
||||||
|
mockAdmin,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockCommandBus.execute).toHaveBeenCalledWith(
|
||||||
|
expect.any(ConfirmBankTransferCommand),
|
||||||
|
);
|
||||||
|
const cmd = mockCommandBus.execute.mock.calls[0]![0] as ConfirmBankTransferCommand;
|
||||||
|
expect(cmd.paymentId).toBe('pay-1');
|
||||||
|
expect(cmd.confirmedBy).toBe('admin-1');
|
||||||
|
expect(cmd.bankReference).toBe('FT123456');
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports omitted bankReference', async () => {
|
||||||
|
mockCommandBus.execute.mockResolvedValue({
|
||||||
|
paymentId: 'pay-2',
|
||||||
|
status: 'COMPLETED',
|
||||||
|
confirmedBy: 'admin-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.confirmBankTransfer('pay-2', {} as any, mockAdmin);
|
||||||
|
|
||||||
|
const cmd = mockCommandBus.execute.mock.calls[0]![0] as ConfirmBankTransferCommand;
|
||||||
|
expect(cmd.paymentId).toBe('pay-2');
|
||||||
|
expect(cmd.confirmedBy).toBe('admin-1');
|
||||||
|
expect(cmd.bankReference).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates errors from the command bus', async () => {
|
||||||
|
mockCommandBus.execute.mockRejectedValue(new Error('validation failed'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.confirmBankTransfer('pay-3', {} as any, mockAdmin),
|
||||||
|
).rejects.toThrow('validation failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { type CommandBus } from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiParam,
|
||||||
|
ApiResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { CurrentUser, JwtAuthGuard, type JwtPayload, Roles, RolesGuard } from '@modules/auth';
|
||||||
|
import { ConfirmBankTransferCommand } from '../../application/commands/confirm-bank-transfer/confirm-bank-transfer.command';
|
||||||
|
import { type ConfirmBankTransferResult } from '../../application/commands/confirm-bank-transfer/confirm-bank-transfer.handler';
|
||||||
|
import { type ConfirmBankTransferDto } from '../dto/confirm-bank-transfer.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-only controller for manual payment reconciliation.
|
||||||
|
*
|
||||||
|
* Separated from the user-facing `PaymentsController` so the audit/RBAC
|
||||||
|
* surface is clearly scoped under `/admin/payments/*`.
|
||||||
|
*/
|
||||||
|
@ApiTags('admin-payments')
|
||||||
|
@ApiBearerAuth('JWT')
|
||||||
|
@Controller('admin/payments')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('ADMIN')
|
||||||
|
export class AdminPaymentsController {
|
||||||
|
constructor(private readonly commandBus: CommandBus) {}
|
||||||
|
|
||||||
|
@Post(':id/confirm-transfer')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Confirm a VN bank transfer payment (admin only)',
|
||||||
|
description:
|
||||||
|
'Marks a pending/processing BANK_TRANSFER payment as COMPLETED. ' +
|
||||||
|
'Emits payment.completed + payment.bank_transfer_confirmed events ' +
|
||||||
|
'so audit logs and subscription activation fire automatically.',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: 'Payment id to confirm' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Bank transfer confirmed successfully' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Payment is not a bank transfer or invalid status' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Unauthorized — missing or invalid JWT' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Forbidden — admin role required' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Payment not found' })
|
||||||
|
async confirmBankTransfer(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: ConfirmBankTransferDto,
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
): Promise<ConfirmBankTransferResult> {
|
||||||
|
return this.commandBus.execute(
|
||||||
|
new ConfirmBankTransferCommand(id, user.sub, dto.bankReference),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { ProjectDevelopmentStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
export class CreateProjectCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly name: string,
|
||||||
|
public readonly slug: string,
|
||||||
|
public readonly developer: string,
|
||||||
|
public readonly developerLogo: string | null,
|
||||||
|
public readonly totalUnits: number,
|
||||||
|
public readonly status: ProjectDevelopmentStatus,
|
||||||
|
public readonly latitude: number,
|
||||||
|
public readonly longitude: number,
|
||||||
|
public readonly address: string,
|
||||||
|
public readonly ward: string,
|
||||||
|
public readonly district: string,
|
||||||
|
public readonly city: string,
|
||||||
|
public readonly description: string | null,
|
||||||
|
public readonly amenities: Record<string, unknown> | null,
|
||||||
|
public readonly masterPlanUrl: string | null,
|
||||||
|
public readonly minPrice: bigint | null,
|
||||||
|
public readonly maxPrice: bigint | null,
|
||||||
|
public readonly pricePerM2Range: Record<string, unknown> | null,
|
||||||
|
public readonly totalArea: number | null,
|
||||||
|
public readonly buildingCount: number | null,
|
||||||
|
public readonly floorCount: number | null,
|
||||||
|
public readonly unitTypes: Record<string, unknown> | null,
|
||||||
|
public readonly tags: string[],
|
||||||
|
public readonly startDate: Date | null,
|
||||||
|
public readonly completionDate: Date | null,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Inject } from '@nestjs/common';
|
||||||
|
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
|
||||||
|
import { createId } from '@paralleldrive/cuid2';
|
||||||
|
import { ConflictException } from '@modules/shared';
|
||||||
|
import { ProjectDevelopmentEntity } from '../../../domain/entities/project-development.entity';
|
||||||
|
import {
|
||||||
|
PROJECT_REPOSITORY,
|
||||||
|
type IProjectRepository,
|
||||||
|
} from '../../../domain/repositories/project-development.repository';
|
||||||
|
import { CreateProjectCommand } from './create-project.command';
|
||||||
|
|
||||||
|
@CommandHandler(CreateProjectCommand)
|
||||||
|
export class CreateProjectHandler implements ICommandHandler<CreateProjectCommand> {
|
||||||
|
constructor(
|
||||||
|
@Inject(PROJECT_REPOSITORY)
|
||||||
|
private readonly repo: IProjectRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(cmd: CreateProjectCommand): Promise<{ id: string; slug: string }> {
|
||||||
|
const existing = await this.repo.findBySlug(cmd.slug);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException(`Dự án với slug "${cmd.slug}" đã tồn tại`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const entity = new ProjectDevelopmentEntity(
|
||||||
|
createId(),
|
||||||
|
{
|
||||||
|
name: cmd.name,
|
||||||
|
slug: cmd.slug,
|
||||||
|
developer: cmd.developer,
|
||||||
|
developerLogo: cmd.developerLogo,
|
||||||
|
totalUnits: cmd.totalUnits,
|
||||||
|
completedUnits: 0,
|
||||||
|
status: cmd.status,
|
||||||
|
startDate: cmd.startDate,
|
||||||
|
completionDate: cmd.completionDate,
|
||||||
|
description: cmd.description,
|
||||||
|
amenities: cmd.amenities,
|
||||||
|
masterPlanUrl: cmd.masterPlanUrl,
|
||||||
|
latitude: cmd.latitude,
|
||||||
|
longitude: cmd.longitude,
|
||||||
|
address: cmd.address,
|
||||||
|
ward: cmd.ward,
|
||||||
|
district: cmd.district,
|
||||||
|
city: cmd.city,
|
||||||
|
minPrice: cmd.minPrice,
|
||||||
|
maxPrice: cmd.maxPrice,
|
||||||
|
pricePerM2Range: cmd.pricePerM2Range,
|
||||||
|
totalArea: cmd.totalArea,
|
||||||
|
buildingCount: cmd.buildingCount,
|
||||||
|
floorCount: cmd.floorCount,
|
||||||
|
unitTypes: cmd.unitTypes,
|
||||||
|
media: null,
|
||||||
|
documents: null,
|
||||||
|
tags: cmd.tags,
|
||||||
|
isVerified: false,
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.repo.save(entity);
|
||||||
|
return { id: entity.id, slug: entity.slug };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { ProjectDevelopmentStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
export class UpdateProjectCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly id: string,
|
||||||
|
public readonly name?: string,
|
||||||
|
public readonly developer?: string,
|
||||||
|
public readonly developerLogo?: string | null,
|
||||||
|
public readonly totalUnits?: number,
|
||||||
|
public readonly completedUnits?: number,
|
||||||
|
public readonly status?: ProjectDevelopmentStatus,
|
||||||
|
public readonly description?: string | null,
|
||||||
|
public readonly amenities?: Record<string, unknown> | null,
|
||||||
|
public readonly masterPlanUrl?: string | null,
|
||||||
|
public readonly minPrice?: bigint | null,
|
||||||
|
public readonly maxPrice?: bigint | null,
|
||||||
|
public readonly pricePerM2Range?: Record<string, unknown> | null,
|
||||||
|
public readonly totalArea?: number | null,
|
||||||
|
public readonly buildingCount?: number | null,
|
||||||
|
public readonly floorCount?: number | null,
|
||||||
|
public readonly unitTypes?: Record<string, unknown> | null,
|
||||||
|
public readonly media?: Record<string, unknown>[] | null,
|
||||||
|
public readonly documents?: Record<string, unknown>[] | null,
|
||||||
|
public readonly tags?: string[],
|
||||||
|
public readonly isVerified?: boolean,
|
||||||
|
public readonly startDate?: Date | null,
|
||||||
|
public readonly completionDate?: Date | null,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Inject } from '@nestjs/common';
|
||||||
|
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
|
||||||
|
import { NotFoundException } from '@modules/shared';
|
||||||
|
import {
|
||||||
|
PROJECT_REPOSITORY,
|
||||||
|
type IProjectRepository,
|
||||||
|
} from '../../../domain/repositories/project-development.repository';
|
||||||
|
import { UpdateProjectCommand } from './update-project.command';
|
||||||
|
|
||||||
|
@CommandHandler(UpdateProjectCommand)
|
||||||
|
export class UpdateProjectHandler implements ICommandHandler<UpdateProjectCommand> {
|
||||||
|
constructor(
|
||||||
|
@Inject(PROJECT_REPOSITORY)
|
||||||
|
private readonly repo: IProjectRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(cmd: UpdateProjectCommand): Promise<{ id: string }> {
|
||||||
|
const entity = await this.repo.findById(cmd.id);
|
||||||
|
if (!entity) {
|
||||||
|
throw new NotFoundException('Dự án', cmd.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.updateDetails({
|
||||||
|
...(cmd.name !== undefined && { name: cmd.name }),
|
||||||
|
...(cmd.developer !== undefined && { developer: cmd.developer }),
|
||||||
|
...(cmd.developerLogo !== undefined && { developerLogo: cmd.developerLogo }),
|
||||||
|
...(cmd.totalUnits !== undefined && { totalUnits: cmd.totalUnits }),
|
||||||
|
...(cmd.completedUnits !== undefined && { completedUnits: cmd.completedUnits }),
|
||||||
|
...(cmd.status !== undefined && { status: cmd.status }),
|
||||||
|
...(cmd.description !== undefined && { description: cmd.description }),
|
||||||
|
...(cmd.amenities !== undefined && { amenities: cmd.amenities }),
|
||||||
|
...(cmd.masterPlanUrl !== undefined && { masterPlanUrl: cmd.masterPlanUrl }),
|
||||||
|
...(cmd.minPrice !== undefined && { minPrice: cmd.minPrice }),
|
||||||
|
...(cmd.maxPrice !== undefined && { maxPrice: cmd.maxPrice }),
|
||||||
|
...(cmd.pricePerM2Range !== undefined && { pricePerM2Range: cmd.pricePerM2Range }),
|
||||||
|
...(cmd.totalArea !== undefined && { totalArea: cmd.totalArea }),
|
||||||
|
...(cmd.buildingCount !== undefined && { buildingCount: cmd.buildingCount }),
|
||||||
|
...(cmd.floorCount !== undefined && { floorCount: cmd.floorCount }),
|
||||||
|
...(cmd.unitTypes !== undefined && { unitTypes: cmd.unitTypes }),
|
||||||
|
...(cmd.media !== undefined && { media: cmd.media }),
|
||||||
|
...(cmd.documents !== undefined && { documents: cmd.documents }),
|
||||||
|
...(cmd.tags !== undefined && { tags: cmd.tags }),
|
||||||
|
...(cmd.isVerified !== undefined && { isVerified: cmd.isVerified }),
|
||||||
|
...(cmd.startDate !== undefined && { startDate: cmd.startDate }),
|
||||||
|
...(cmd.completionDate !== undefined && { completionDate: cmd.completionDate }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.repo.update(entity);
|
||||||
|
return { id: entity.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Inject } from '@nestjs/common';
|
||||||
|
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
PROJECT_REPOSITORY,
|
||||||
|
type IProjectRepository,
|
||||||
|
type ProjectDetailData,
|
||||||
|
} from '../../../domain/repositories/project-development.repository';
|
||||||
|
import { GetProjectQuery } from './get-project.query';
|
||||||
|
|
||||||
|
@QueryHandler(GetProjectQuery)
|
||||||
|
export class GetProjectHandler implements IQueryHandler<GetProjectQuery> {
|
||||||
|
constructor(
|
||||||
|
@Inject(PROJECT_REPOSITORY)
|
||||||
|
private readonly repo: IProjectRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: GetProjectQuery): Promise<ProjectDetailData | null> {
|
||||||
|
// Try slug first, then ID
|
||||||
|
const bySlug = await this.repo.findDetailBySlug(query.slugOrId);
|
||||||
|
if (bySlug) return bySlug;
|
||||||
|
return this.repo.findDetailById(query.slugOrId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export class GetProjectQuery {
|
||||||
|
constructor(public readonly slugOrId: string) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Inject } from '@nestjs/common';
|
||||||
|
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
PROJECT_REPOSITORY,
|
||||||
|
type IProjectRepository,
|
||||||
|
type PaginatedResult,
|
||||||
|
type ProjectListItem,
|
||||||
|
} from '../../../domain/repositories/project-development.repository';
|
||||||
|
import { ListProjectsQuery } from './list-projects.query';
|
||||||
|
|
||||||
|
@QueryHandler(ListProjectsQuery)
|
||||||
|
export class ListProjectsHandler implements IQueryHandler<ListProjectsQuery> {
|
||||||
|
constructor(
|
||||||
|
@Inject(PROJECT_REPOSITORY)
|
||||||
|
private readonly repo: IProjectRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: ListProjectsQuery): Promise<PaginatedResult<ProjectListItem>> {
|
||||||
|
return this.repo.search({
|
||||||
|
query: query.query,
|
||||||
|
status: query.status,
|
||||||
|
city: query.city,
|
||||||
|
district: query.district,
|
||||||
|
developer: query.developer,
|
||||||
|
isVerified: query.isVerified,
|
||||||
|
page: query.page,
|
||||||
|
limit: query.limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ProjectDevelopmentStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
export class ListProjectsQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly query: string | undefined,
|
||||||
|
public readonly status: ProjectDevelopmentStatus | undefined,
|
||||||
|
public readonly city: string | undefined,
|
||||||
|
public readonly district: string | undefined,
|
||||||
|
public readonly developer: string | undefined,
|
||||||
|
public readonly isVerified: boolean | undefined,
|
||||||
|
public readonly page: number,
|
||||||
|
public readonly limit: number,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { type ProjectDevelopmentStatus } from '@prisma/client';
|
||||||
|
import { AggregateRoot } from '@modules/shared';
|
||||||
|
|
||||||
|
export interface ProjectDevelopmentProps {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
developer: string;
|
||||||
|
developerLogo: string | null;
|
||||||
|
totalUnits: number;
|
||||||
|
completedUnits: number;
|
||||||
|
status: ProjectDevelopmentStatus;
|
||||||
|
startDate: Date | null;
|
||||||
|
completionDate: Date | null;
|
||||||
|
description: string | null;
|
||||||
|
amenities: Record<string, unknown> | null;
|
||||||
|
masterPlanUrl: string | null;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
address: string;
|
||||||
|
ward: string;
|
||||||
|
district: string;
|
||||||
|
city: string;
|
||||||
|
minPrice: bigint | null;
|
||||||
|
maxPrice: bigint | null;
|
||||||
|
pricePerM2Range: Record<string, unknown> | null;
|
||||||
|
totalArea: number | null;
|
||||||
|
buildingCount: number | null;
|
||||||
|
floorCount: number | null;
|
||||||
|
unitTypes: Record<string, unknown> | null;
|
||||||
|
media: Record<string, unknown>[] | null;
|
||||||
|
documents: Record<string, unknown>[] | null;
|
||||||
|
tags: string[];
|
||||||
|
isVerified: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProjectDevelopmentEntity extends AggregateRoot<string> {
|
||||||
|
private _name: string;
|
||||||
|
private _slug: string;
|
||||||
|
private _developer: string;
|
||||||
|
private _developerLogo: string | null;
|
||||||
|
private _totalUnits: number;
|
||||||
|
private _completedUnits: number;
|
||||||
|
private _status: ProjectDevelopmentStatus;
|
||||||
|
private _startDate: Date | null;
|
||||||
|
private _completionDate: Date | null;
|
||||||
|
private _description: string | null;
|
||||||
|
private _amenities: Record<string, unknown> | null;
|
||||||
|
private _masterPlanUrl: string | null;
|
||||||
|
private _latitude: number;
|
||||||
|
private _longitude: number;
|
||||||
|
private _address: string;
|
||||||
|
private _ward: string;
|
||||||
|
private _district: string;
|
||||||
|
private _city: string;
|
||||||
|
private _minPrice: bigint | null;
|
||||||
|
private _maxPrice: bigint | null;
|
||||||
|
private _pricePerM2Range: Record<string, unknown> | null;
|
||||||
|
private _totalArea: number | null;
|
||||||
|
private _buildingCount: number | null;
|
||||||
|
private _floorCount: number | null;
|
||||||
|
private _unitTypes: Record<string, unknown> | null;
|
||||||
|
private _media: Record<string, unknown>[] | null;
|
||||||
|
private _documents: Record<string, unknown>[] | null;
|
||||||
|
private _tags: string[];
|
||||||
|
private _isVerified: boolean;
|
||||||
|
|
||||||
|
constructor(id: string, props: ProjectDevelopmentProps, createdAt: Date, updatedAt: Date) {
|
||||||
|
super(id, createdAt, updatedAt);
|
||||||
|
this._name = props.name;
|
||||||
|
this._slug = props.slug;
|
||||||
|
this._developer = props.developer;
|
||||||
|
this._developerLogo = props.developerLogo;
|
||||||
|
this._totalUnits = props.totalUnits;
|
||||||
|
this._completedUnits = props.completedUnits;
|
||||||
|
this._status = props.status;
|
||||||
|
this._startDate = props.startDate;
|
||||||
|
this._completionDate = props.completionDate;
|
||||||
|
this._description = props.description;
|
||||||
|
this._amenities = props.amenities;
|
||||||
|
this._masterPlanUrl = props.masterPlanUrl;
|
||||||
|
this._latitude = props.latitude;
|
||||||
|
this._longitude = props.longitude;
|
||||||
|
this._address = props.address;
|
||||||
|
this._ward = props.ward;
|
||||||
|
this._district = props.district;
|
||||||
|
this._city = props.city;
|
||||||
|
this._minPrice = props.minPrice;
|
||||||
|
this._maxPrice = props.maxPrice;
|
||||||
|
this._pricePerM2Range = props.pricePerM2Range;
|
||||||
|
this._totalArea = props.totalArea;
|
||||||
|
this._buildingCount = props.buildingCount;
|
||||||
|
this._floorCount = props.floorCount;
|
||||||
|
this._unitTypes = props.unitTypes;
|
||||||
|
this._media = props.media;
|
||||||
|
this._documents = props.documents;
|
||||||
|
this._tags = props.tags;
|
||||||
|
this._isVerified = props.isVerified;
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() { return this._name; }
|
||||||
|
get slug() { return this._slug; }
|
||||||
|
get developer() { return this._developer; }
|
||||||
|
get developerLogo() { return this._developerLogo; }
|
||||||
|
get totalUnits() { return this._totalUnits; }
|
||||||
|
get completedUnits() { return this._completedUnits; }
|
||||||
|
get status() { return this._status; }
|
||||||
|
get startDate() { return this._startDate; }
|
||||||
|
get completionDate() { return this._completionDate; }
|
||||||
|
get description() { return this._description; }
|
||||||
|
get amenities() { return this._amenities; }
|
||||||
|
get masterPlanUrl() { return this._masterPlanUrl; }
|
||||||
|
get latitude() { return this._latitude; }
|
||||||
|
get longitude() { return this._longitude; }
|
||||||
|
get address() { return this._address; }
|
||||||
|
get ward() { return this._ward; }
|
||||||
|
get district() { return this._district; }
|
||||||
|
get city() { return this._city; }
|
||||||
|
get minPrice() { return this._minPrice; }
|
||||||
|
get maxPrice() { return this._maxPrice; }
|
||||||
|
get pricePerM2Range() { return this._pricePerM2Range; }
|
||||||
|
get totalArea() { return this._totalArea; }
|
||||||
|
get buildingCount() { return this._buildingCount; }
|
||||||
|
get floorCount() { return this._floorCount; }
|
||||||
|
get unitTypes() { return this._unitTypes; }
|
||||||
|
get media() { return this._media; }
|
||||||
|
get documents() { return this._documents; }
|
||||||
|
get tags() { return this._tags; }
|
||||||
|
get isVerified() { return this._isVerified; }
|
||||||
|
|
||||||
|
updateDetails(props: Partial<ProjectDevelopmentProps>): void {
|
||||||
|
if (props.name !== undefined) this._name = props.name;
|
||||||
|
if (props.developer !== undefined) this._developer = props.developer;
|
||||||
|
if (props.developerLogo !== undefined) this._developerLogo = props.developerLogo;
|
||||||
|
if (props.totalUnits !== undefined) this._totalUnits = props.totalUnits;
|
||||||
|
if (props.completedUnits !== undefined) this._completedUnits = props.completedUnits;
|
||||||
|
if (props.status !== undefined) this._status = props.status;
|
||||||
|
if (props.startDate !== undefined) this._startDate = props.startDate;
|
||||||
|
if (props.completionDate !== undefined) this._completionDate = props.completionDate;
|
||||||
|
if (props.description !== undefined) this._description = props.description;
|
||||||
|
if (props.amenities !== undefined) this._amenities = props.amenities;
|
||||||
|
if (props.masterPlanUrl !== undefined) this._masterPlanUrl = props.masterPlanUrl;
|
||||||
|
if (props.minPrice !== undefined) this._minPrice = props.minPrice;
|
||||||
|
if (props.maxPrice !== undefined) this._maxPrice = props.maxPrice;
|
||||||
|
if (props.pricePerM2Range !== undefined) this._pricePerM2Range = props.pricePerM2Range;
|
||||||
|
if (props.totalArea !== undefined) this._totalArea = props.totalArea;
|
||||||
|
if (props.buildingCount !== undefined) this._buildingCount = props.buildingCount;
|
||||||
|
if (props.floorCount !== undefined) this._floorCount = props.floorCount;
|
||||||
|
if (props.unitTypes !== undefined) this._unitTypes = props.unitTypes;
|
||||||
|
if (props.media !== undefined) this._media = props.media;
|
||||||
|
if (props.documents !== undefined) this._documents = props.documents;
|
||||||
|
if (props.tags !== undefined) this._tags = props.tags;
|
||||||
|
if (props.isVerified !== undefined) this._isVerified = props.isVerified;
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { ProjectDevelopmentStatus } from '@prisma/client';
|
||||||
|
import type { ProjectDevelopmentEntity } from '../entities/project-development.entity';
|
||||||
|
|
||||||
|
export const PROJECT_REPOSITORY = Symbol('PROJECT_REPOSITORY');
|
||||||
|
|
||||||
|
export interface ProjectSearchParams {
|
||||||
|
query?: string;
|
||||||
|
status?: ProjectDevelopmentStatus;
|
||||||
|
city?: string;
|
||||||
|
district?: string;
|
||||||
|
developer?: string;
|
||||||
|
isVerified?: boolean;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResult<T> {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectListItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
developer: string;
|
||||||
|
developerLogo: string | null;
|
||||||
|
status: ProjectDevelopmentStatus;
|
||||||
|
totalUnits: number;
|
||||||
|
completedUnits: number;
|
||||||
|
address: string;
|
||||||
|
ward: string;
|
||||||
|
district: string;
|
||||||
|
city: string;
|
||||||
|
minPrice: bigint | null;
|
||||||
|
maxPrice: bigint | null;
|
||||||
|
totalArea: number | null;
|
||||||
|
tags: string[];
|
||||||
|
isVerified: boolean;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
propertyCount: number;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectDetailData extends ProjectListItem {
|
||||||
|
startDate: Date | null;
|
||||||
|
completionDate: Date | null;
|
||||||
|
description: string | null;
|
||||||
|
amenities: Record<string, unknown> | null;
|
||||||
|
masterPlanUrl: string | null;
|
||||||
|
pricePerM2Range: Record<string, unknown> | null;
|
||||||
|
buildingCount: number | null;
|
||||||
|
floorCount: number | null;
|
||||||
|
unitTypes: Record<string, unknown> | null;
|
||||||
|
media: Record<string, unknown>[] | null;
|
||||||
|
documents: Record<string, unknown>[] | null;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IProjectRepository {
|
||||||
|
findById(id: string): Promise<ProjectDevelopmentEntity | null>;
|
||||||
|
findBySlug(slug: string): Promise<ProjectDevelopmentEntity | null>;
|
||||||
|
findDetailBySlug(slug: string): Promise<ProjectDetailData | null>;
|
||||||
|
findDetailById(id: string): Promise<ProjectDetailData | null>;
|
||||||
|
save(entity: ProjectDevelopmentEntity): Promise<void>;
|
||||||
|
update(entity: ProjectDevelopmentEntity): Promise<void>;
|
||||||
|
search(params: ProjectSearchParams): Promise<PaginatedResult<ProjectListItem>>;
|
||||||
|
}
|
||||||
7
apps/api/src/modules/projects/index.ts
Normal file
7
apps/api/src/modules/projects/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { ProjectsModule } from './projects.module';
|
||||||
|
export { PROJECT_REPOSITORY } from './domain/repositories/project-development.repository';
|
||||||
|
export type {
|
||||||
|
IProjectRepository,
|
||||||
|
ProjectDetailData,
|
||||||
|
ProjectListItem,
|
||||||
|
} from './domain/repositories/project-development.repository';
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
import { type PrismaService } from '@modules/shared';
|
||||||
|
import { ProjectDevelopmentEntity } from '../../domain/entities/project-development.entity';
|
||||||
|
import type {
|
||||||
|
IProjectRepository,
|
||||||
|
ProjectSearchParams,
|
||||||
|
PaginatedResult,
|
||||||
|
ProjectListItem,
|
||||||
|
ProjectDetailData,
|
||||||
|
} from '../../domain/repositories/project-development.repository';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaProjectDevelopmentRepository implements IProjectRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<ProjectDevelopmentEntity | null> {
|
||||||
|
const row = await this.prisma.$queryRaw<RawProject[]>`
|
||||||
|
SELECT *, ST_Y(location::geometry) as lat, ST_X(location::geometry) as lng
|
||||||
|
FROM "ProjectDevelopment" WHERE id = ${id} LIMIT 1
|
||||||
|
`;
|
||||||
|
return row[0] ? this.toDomain(row[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findBySlug(slug: string): Promise<ProjectDevelopmentEntity | null> {
|
||||||
|
const row = await this.prisma.$queryRaw<RawProject[]>`
|
||||||
|
SELECT *, ST_Y(location::geometry) as lat, ST_X(location::geometry) as lng
|
||||||
|
FROM "ProjectDevelopment" WHERE slug = ${slug} LIMIT 1
|
||||||
|
`;
|
||||||
|
return row[0] ? this.toDomain(row[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findDetailBySlug(slug: string): Promise<ProjectDetailData | null> {
|
||||||
|
const rows = await this.prisma.$queryRaw<RawProjectDetail[]>`
|
||||||
|
SELECT p.*,
|
||||||
|
ST_Y(p.location::geometry) as lat,
|
||||||
|
ST_X(p.location::geometry) as lng,
|
||||||
|
COUNT(pr.id)::int as "propertyCount"
|
||||||
|
FROM "ProjectDevelopment" p
|
||||||
|
LEFT JOIN "Property" pr ON pr."projectId" = p.id
|
||||||
|
WHERE p.slug = ${slug}
|
||||||
|
GROUP BY p.id
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
return rows[0] ? this.toDetail(rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findDetailById(id: string): Promise<ProjectDetailData | null> {
|
||||||
|
const rows = await this.prisma.$queryRaw<RawProjectDetail[]>`
|
||||||
|
SELECT p.*,
|
||||||
|
ST_Y(p.location::geometry) as lat,
|
||||||
|
ST_X(p.location::geometry) as lng,
|
||||||
|
COUNT(pr.id)::int as "propertyCount"
|
||||||
|
FROM "ProjectDevelopment" p
|
||||||
|
LEFT JOIN "Property" pr ON pr."projectId" = p.id
|
||||||
|
WHERE p.id = ${id}
|
||||||
|
GROUP BY p.id
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
return rows[0] ? this.toDetail(rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(entity: ProjectDevelopmentEntity): Promise<void> {
|
||||||
|
await this.prisma.$executeRaw`
|
||||||
|
INSERT INTO "ProjectDevelopment" (
|
||||||
|
id, name, slug, developer, "developerLogo", "totalUnits", "completedUnits",
|
||||||
|
status, "startDate", "completionDate", description, amenities, "masterPlanUrl",
|
||||||
|
location, address, ward, district, city,
|
||||||
|
"minPrice", "maxPrice", "pricePerM2Range", "totalArea",
|
||||||
|
"buildingCount", "floorCount", "unitTypes", media, documents,
|
||||||
|
tags, "isVerified", "createdAt", "updatedAt"
|
||||||
|
) VALUES (
|
||||||
|
${entity.id}, ${entity.name}, ${entity.slug}, ${entity.developer},
|
||||||
|
${entity.developerLogo}, ${entity.totalUnits}, ${entity.completedUnits},
|
||||||
|
${entity.status}::"ProjectDevelopmentStatus",
|
||||||
|
${entity.startDate}, ${entity.completionDate},
|
||||||
|
${entity.description},
|
||||||
|
${entity.amenities ? JSON.stringify(entity.amenities) : null}::jsonb,
|
||||||
|
${entity.masterPlanUrl},
|
||||||
|
ST_SetSRID(ST_MakePoint(${entity.longitude}, ${entity.latitude}), 4326),
|
||||||
|
${entity.address}, ${entity.ward}, ${entity.district}, ${entity.city},
|
||||||
|
${entity.minPrice}, ${entity.maxPrice},
|
||||||
|
${entity.pricePerM2Range ? JSON.stringify(entity.pricePerM2Range) : null}::jsonb,
|
||||||
|
${entity.totalArea}, ${entity.buildingCount}, ${entity.floorCount},
|
||||||
|
${entity.unitTypes ? JSON.stringify(entity.unitTypes) : null}::jsonb,
|
||||||
|
${entity.media ? JSON.stringify(entity.media) : null}::jsonb,
|
||||||
|
${entity.documents ? JSON.stringify(entity.documents) : null}::jsonb,
|
||||||
|
${entity.tags}::text[],
|
||||||
|
${entity.isVerified}, ${entity.createdAt}, ${entity.updatedAt}
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(entity: ProjectDevelopmentEntity): Promise<void> {
|
||||||
|
await this.prisma.$executeRaw`
|
||||||
|
UPDATE "ProjectDevelopment" SET
|
||||||
|
name = ${entity.name}, developer = ${entity.developer},
|
||||||
|
"developerLogo" = ${entity.developerLogo},
|
||||||
|
"totalUnits" = ${entity.totalUnits}, "completedUnits" = ${entity.completedUnits},
|
||||||
|
status = ${entity.status}::"ProjectDevelopmentStatus",
|
||||||
|
"startDate" = ${entity.startDate}, "completionDate" = ${entity.completionDate},
|
||||||
|
description = ${entity.description},
|
||||||
|
amenities = ${entity.amenities ? JSON.stringify(entity.amenities) : null}::jsonb,
|
||||||
|
"masterPlanUrl" = ${entity.masterPlanUrl},
|
||||||
|
"minPrice" = ${entity.minPrice}, "maxPrice" = ${entity.maxPrice},
|
||||||
|
"pricePerM2Range" = ${entity.pricePerM2Range ? JSON.stringify(entity.pricePerM2Range) : null}::jsonb,
|
||||||
|
"totalArea" = ${entity.totalArea},
|
||||||
|
"buildingCount" = ${entity.buildingCount}, "floorCount" = ${entity.floorCount},
|
||||||
|
"unitTypes" = ${entity.unitTypes ? JSON.stringify(entity.unitTypes) : null}::jsonb,
|
||||||
|
media = ${entity.media ? JSON.stringify(entity.media) : null}::jsonb,
|
||||||
|
documents = ${entity.documents ? JSON.stringify(entity.documents) : null}::jsonb,
|
||||||
|
tags = ${entity.tags}::text[],
|
||||||
|
"isVerified" = ${entity.isVerified},
|
||||||
|
"updatedAt" = ${entity.updatedAt}
|
||||||
|
WHERE id = ${entity.id}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(params: ProjectSearchParams): Promise<PaginatedResult<ProjectListItem>> {
|
||||||
|
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.status) {
|
||||||
|
conditions.push(`status = $${paramIndex++}::"ProjectDevelopmentStatus"`);
|
||||||
|
values.push(params.status);
|
||||||
|
}
|
||||||
|
if (params.city) {
|
||||||
|
conditions.push(`city = $${paramIndex++}`);
|
||||||
|
values.push(params.city);
|
||||||
|
}
|
||||||
|
if (params.district) {
|
||||||
|
conditions.push(`district = $${paramIndex++}`);
|
||||||
|
values.push(params.district);
|
||||||
|
}
|
||||||
|
if (params.developer) {
|
||||||
|
conditions.push(`developer ILIKE $${paramIndex++}`);
|
||||||
|
values.push(`%${params.developer}%`);
|
||||||
|
}
|
||||||
|
if (params.isVerified !== undefined) {
|
||||||
|
conditions.push(`"isVerified" = $${paramIndex++}`);
|
||||||
|
values.push(params.isVerified);
|
||||||
|
}
|
||||||
|
if (params.query) {
|
||||||
|
conditions.push(`(name ILIKE $${paramIndex} OR developer ILIKE $${paramIndex} OR district ILIKE $${paramIndex} OR city 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 "ProjectDevelopment" WHERE ${where}`,
|
||||||
|
...values,
|
||||||
|
);
|
||||||
|
const total = Number(countResult[0].count);
|
||||||
|
|
||||||
|
const rows = await this.prisma.$queryRawUnsafe<RawProjectDetail[]>(
|
||||||
|
`SELECT p.*, ST_Y(p.location::geometry) as lat, ST_X(p.location::geometry) as lng,
|
||||||
|
COUNT(pr.id)::int as "propertyCount"
|
||||||
|
FROM "ProjectDevelopment" p
|
||||||
|
LEFT JOIN "Property" pr ON pr."projectId" = p.id
|
||||||
|
WHERE ${where.replace(/\b(\$\d+)/g, (_, m) => m)}
|
||||||
|
GROUP BY p.id
|
||||||
|
ORDER BY p."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: RawProject): ProjectDevelopmentEntity {
|
||||||
|
return new ProjectDevelopmentEntity(
|
||||||
|
row.id,
|
||||||
|
{
|
||||||
|
name: row.name,
|
||||||
|
slug: row.slug,
|
||||||
|
developer: row.developer,
|
||||||
|
developerLogo: row.developerLogo,
|
||||||
|
totalUnits: row.totalUnits,
|
||||||
|
completedUnits: row.completedUnits,
|
||||||
|
status: row.status,
|
||||||
|
startDate: row.startDate,
|
||||||
|
completionDate: row.completionDate,
|
||||||
|
description: row.description,
|
||||||
|
amenities: row.amenities as Record<string, unknown> | null,
|
||||||
|
masterPlanUrl: row.masterPlanUrl,
|
||||||
|
latitude: Number(row.lat),
|
||||||
|
longitude: Number(row.lng),
|
||||||
|
address: row.address,
|
||||||
|
ward: row.ward,
|
||||||
|
district: row.district,
|
||||||
|
city: row.city,
|
||||||
|
minPrice: row.minPrice,
|
||||||
|
maxPrice: row.maxPrice,
|
||||||
|
pricePerM2Range: row.pricePerM2Range as Record<string, unknown> | null,
|
||||||
|
totalArea: row.totalArea,
|
||||||
|
buildingCount: row.buildingCount,
|
||||||
|
floorCount: row.floorCount,
|
||||||
|
unitTypes: row.unitTypes as Record<string, unknown> | null,
|
||||||
|
media: row.media as Record<string, unknown>[] | null,
|
||||||
|
documents: row.documents as Record<string, unknown>[] | null,
|
||||||
|
tags: row.tags ?? [],
|
||||||
|
isVerified: row.isVerified,
|
||||||
|
},
|
||||||
|
row.createdAt,
|
||||||
|
row.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toListItem(row: RawProjectDetail): ProjectListItem {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
slug: row.slug,
|
||||||
|
developer: row.developer,
|
||||||
|
developerLogo: row.developerLogo,
|
||||||
|
status: row.status,
|
||||||
|
totalUnits: row.totalUnits,
|
||||||
|
completedUnits: row.completedUnits,
|
||||||
|
address: row.address,
|
||||||
|
ward: row.ward,
|
||||||
|
district: row.district,
|
||||||
|
city: row.city,
|
||||||
|
minPrice: row.minPrice,
|
||||||
|
maxPrice: row.maxPrice,
|
||||||
|
totalArea: row.totalArea,
|
||||||
|
tags: row.tags ?? [],
|
||||||
|
isVerified: row.isVerified,
|
||||||
|
latitude: Number(row.lat),
|
||||||
|
longitude: Number(row.lng),
|
||||||
|
propertyCount: row.propertyCount ?? 0,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDetail(row: RawProjectDetail): ProjectDetailData {
|
||||||
|
return {
|
||||||
|
...this.toListItem(row),
|
||||||
|
startDate: row.startDate,
|
||||||
|
completionDate: row.completionDate,
|
||||||
|
description: row.description,
|
||||||
|
amenities: row.amenities as Record<string, unknown> | null,
|
||||||
|
masterPlanUrl: row.masterPlanUrl,
|
||||||
|
pricePerM2Range: row.pricePerM2Range as Record<string, unknown> | null,
|
||||||
|
buildingCount: row.buildingCount,
|
||||||
|
floorCount: row.floorCount,
|
||||||
|
unitTypes: row.unitTypes as Record<string, unknown> | null,
|
||||||
|
media: row.media as Record<string, unknown>[] | null,
|
||||||
|
documents: row.documents as Record<string, unknown>[] | null,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawProject {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
developer: string;
|
||||||
|
developerLogo: string | null;
|
||||||
|
totalUnits: number;
|
||||||
|
completedUnits: number;
|
||||||
|
status: 'PLANNING' | 'UNDER_CONSTRUCTION' | 'COMPLETED' | 'HANDOVER';
|
||||||
|
startDate: Date | null;
|
||||||
|
completionDate: Date | null;
|
||||||
|
description: string | null;
|
||||||
|
amenities: Prisma.JsonValue;
|
||||||
|
masterPlanUrl: string | null;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
address: string;
|
||||||
|
ward: string;
|
||||||
|
district: string;
|
||||||
|
city: string;
|
||||||
|
minPrice: bigint | null;
|
||||||
|
maxPrice: bigint | null;
|
||||||
|
pricePerM2Range: Prisma.JsonValue;
|
||||||
|
totalArea: number | null;
|
||||||
|
buildingCount: number | null;
|
||||||
|
floorCount: number | null;
|
||||||
|
unitTypes: Prisma.JsonValue;
|
||||||
|
media: Prisma.JsonValue;
|
||||||
|
documents: Prisma.JsonValue;
|
||||||
|
tags: string[] | null;
|
||||||
|
isVerified: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawProjectDetail extends RawProject {
|
||||||
|
propertyCount: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { Body, Controller, 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 { UserRole } from '@prisma/client';
|
||||||
|
import { JwtAuthGuard, Roles, RolesGuard } from '@modules/auth';
|
||||||
|
import { NotFoundException } from '@modules/shared';
|
||||||
|
import { CreateProjectCommand } from '../../application/commands/create-project/create-project.command';
|
||||||
|
import { UpdateProjectCommand } from '../../application/commands/update-project/update-project.command';
|
||||||
|
import { GetProjectQuery } from '../../application/queries/get-project/get-project.query';
|
||||||
|
import { ListProjectsQuery } from '../../application/queries/list-projects/list-projects.query';
|
||||||
|
import { type CreateProjectDto } from '../dto/create-project.dto';
|
||||||
|
import { type SearchProjectsDto } from '../dto/search-projects.dto';
|
||||||
|
import { type UpdateProjectDto } from '../dto/update-project.dto';
|
||||||
|
|
||||||
|
@ApiTags('projects')
|
||||||
|
@Controller('projects')
|
||||||
|
export class ProjectsController {
|
||||||
|
constructor(
|
||||||
|
private readonly commandBus: CommandBus,
|
||||||
|
private readonly queryBus: QueryBus,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ── Public endpoints ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@ApiOperation({ summary: 'Danh sách dự án', description: 'Tìm kiếm và lọc dự án bất động sản' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Danh sách dự án phân trang' })
|
||||||
|
@Get()
|
||||||
|
async listProjects(@Query() dto: SearchProjectsDto) {
|
||||||
|
return this.queryBus.execute(
|
||||||
|
new ListProjectsQuery(
|
||||||
|
dto.q,
|
||||||
|
dto.status,
|
||||||
|
dto.city,
|
||||||
|
dto.district,
|
||||||
|
dto.developer,
|
||||||
|
dto.isVerified,
|
||||||
|
dto.page ?? 1,
|
||||||
|
dto.limit ?? 20,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: 'Chi tiết dự án', description: 'Xem chi tiết dự án theo slug hoặc ID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Thông tin chi tiết dự án' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Không tìm thấy dự án' })
|
||||||
|
@Get(':slugOrId')
|
||||||
|
async getProject(@Param('slugOrId') slugOrId: string) {
|
||||||
|
const result = await this.queryBus.execute(new GetProjectQuery(slugOrId));
|
||||||
|
if (!result) {
|
||||||
|
throw new NotFoundException('Dự án', slugOrId);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin endpoints ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@ApiOperation({ summary: 'Tạo dự án (admin)', description: 'Tạo mới dự án bất động sản' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Dự án đã tạo' })
|
||||||
|
@ApiBearerAuth('JWT')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@Post()
|
||||||
|
async createProject(@Body() dto: CreateProjectDto) {
|
||||||
|
return this.commandBus.execute(
|
||||||
|
new CreateProjectCommand(
|
||||||
|
dto.name,
|
||||||
|
dto.slug,
|
||||||
|
dto.developer,
|
||||||
|
dto.developerLogo ?? null,
|
||||||
|
dto.totalUnits,
|
||||||
|
dto.status,
|
||||||
|
dto.latitude,
|
||||||
|
dto.longitude,
|
||||||
|
dto.address,
|
||||||
|
dto.ward,
|
||||||
|
dto.district,
|
||||||
|
dto.city,
|
||||||
|
dto.description ?? null,
|
||||||
|
dto.amenities ?? null,
|
||||||
|
dto.masterPlanUrl ?? null,
|
||||||
|
dto.minPrice ? BigInt(dto.minPrice) : null,
|
||||||
|
dto.maxPrice ? BigInt(dto.maxPrice) : null,
|
||||||
|
dto.pricePerM2Range ?? null,
|
||||||
|
dto.totalArea ?? null,
|
||||||
|
dto.buildingCount ?? null,
|
||||||
|
dto.floorCount ?? null,
|
||||||
|
dto.unitTypes ?? null,
|
||||||
|
dto.tags ?? [],
|
||||||
|
dto.startDate ? new Date(dto.startDate) : null,
|
||||||
|
dto.completionDate ? new Date(dto.completionDate) : null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: 'Cập nhật dự án (admin)', description: 'Cập nhật thông tin dự án' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Dự án đã cập nhật' })
|
||||||
|
@ApiBearerAuth('JWT')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@Patch(':id')
|
||||||
|
async updateProject(@Param('id') id: string, @Body() dto: UpdateProjectDto) {
|
||||||
|
return this.commandBus.execute(
|
||||||
|
new UpdateProjectCommand(
|
||||||
|
id,
|
||||||
|
dto.name,
|
||||||
|
dto.developer,
|
||||||
|
dto.developerLogo,
|
||||||
|
dto.totalUnits,
|
||||||
|
dto.completedUnits,
|
||||||
|
dto.status,
|
||||||
|
dto.description,
|
||||||
|
dto.amenities,
|
||||||
|
dto.masterPlanUrl,
|
||||||
|
dto.minPrice !== undefined ? (dto.minPrice ? BigInt(dto.minPrice) : null) : undefined,
|
||||||
|
dto.maxPrice !== undefined ? (dto.maxPrice ? BigInt(dto.maxPrice) : null) : undefined,
|
||||||
|
dto.pricePerM2Range,
|
||||||
|
dto.totalArea,
|
||||||
|
dto.buildingCount,
|
||||||
|
dto.floorCount,
|
||||||
|
dto.unitTypes,
|
||||||
|
dto.media,
|
||||||
|
dto.documents,
|
||||||
|
dto.tags,
|
||||||
|
dto.isVerified,
|
||||||
|
dto.startDate !== undefined ? (dto.startDate ? new Date(dto.startDate) : null) : undefined,
|
||||||
|
dto.completionDate !== undefined ? (dto.completionDate ? new Date(dto.completionDate) : null) : undefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { ProjectDevelopmentStatus } from '@prisma/client';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsNumber,
|
||||||
|
IsEnum,
|
||||||
|
IsOptional,
|
||||||
|
IsArray,
|
||||||
|
IsObject,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
IsDateString,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateProjectDto {
|
||||||
|
@ApiProperty({ example: 'Vinhomes Grand Park', description: 'Tên dự án' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'vinhomes-grand-park', description: 'URL slug (unique)' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
slug!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Vingroup' })
|
||||||
|
@IsString()
|
||||||
|
developer!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'https://example.com/logo.png' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
developerLogo?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 10000, description: 'Tổng số căn hộ/đơn vị' })
|
||||||
|
@IsNumber()
|
||||||
|
@Type(() => Number)
|
||||||
|
@Min(1)
|
||||||
|
totalUnits!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ProjectDevelopmentStatus, example: 'UNDER_CONSTRUCTION' })
|
||||||
|
@IsEnum(ProjectDevelopmentStatus)
|
||||||
|
status!: ProjectDevelopmentStatus;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 10.8231, description: 'Latitude' })
|
||||||
|
@IsNumber()
|
||||||
|
@Type(() => Number)
|
||||||
|
@Min(-90)
|
||||||
|
@Max(90)
|
||||||
|
latitude!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 106.8368, description: 'Longitude' })
|
||||||
|
@IsNumber()
|
||||||
|
@Type(() => Number)
|
||||||
|
@Min(-180)
|
||||||
|
@Max(180)
|
||||||
|
longitude!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Phường Long Thạnh Mỹ, TP. Thủ Đức' })
|
||||||
|
@IsString()
|
||||||
|
address!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Long Thạnh Mỹ' })
|
||||||
|
@IsString()
|
||||||
|
ward!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Thủ Đức' })
|
||||||
|
@IsString()
|
||||||
|
district!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Hồ Chí Minh' })
|
||||||
|
@IsString()
|
||||||
|
city!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Mô tả dự án' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Tiện ích dự án (JSON)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
amenities?: Record<string, unknown>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'https://example.com/masterplan.jpg' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
masterPlanUrl?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '3000000000', description: 'Giá thấp nhất (VND)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
minPrice?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '15000000000', description: 'Giá cao nhất (VND)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
maxPrice?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Giá/m² range (JSON)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
pricePerM2Range?: Record<string, unknown>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 271, description: 'Tổng diện tích (ha)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Type(() => Number)
|
||||||
|
@Min(0)
|
||||||
|
totalArea?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 14 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Type(() => Number)
|
||||||
|
buildingCount?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 35 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Type(() => Number)
|
||||||
|
floorCount?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Loại căn hộ (JSON)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
unitTypes?: Record<string, unknown>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: ['cao-cap', 'can-ho'], description: 'Tags' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '2020-06-01', description: 'Ngày khởi công' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
startDate?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '2025-12-31', description: 'Ngày dự kiến hoàn thành' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
completionDate?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { ProjectDevelopmentStatus } from '@prisma/client';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsString, IsEnum, IsOptional, IsNumber, IsBoolean, Min, Max } from 'class-validator';
|
||||||
|
|
||||||
|
export class SearchProjectsDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Tìm kiếm theo tên, chủ đầu tư, quận, thành phố' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
q?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ProjectDevelopmentStatus })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ProjectDevelopmentStatus)
|
||||||
|
status?: ProjectDevelopmentStatus;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Hồ Chí Minh' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
city?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Thủ Đức' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
district?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Vingroup' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
developer?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
@Type(() => Boolean)
|
||||||
|
isVerified?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Type(() => Number)
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: 20 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Type(() => Number)
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { ProjectDevelopmentStatus } from '@prisma/client';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsNumber,
|
||||||
|
IsEnum,
|
||||||
|
IsOptional,
|
||||||
|
IsArray,
|
||||||
|
IsObject,
|
||||||
|
IsBoolean,
|
||||||
|
Min,
|
||||||
|
IsDateString,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateProjectDto {
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsString() name?: string;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsString() developer?: string;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsString() developerLogo?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsNumber() @Type(() => Number) @Min(1)
|
||||||
|
totalUnits?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsNumber() @Type(() => Number) @Min(0)
|
||||||
|
completedUnits?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ProjectDevelopmentStatus })
|
||||||
|
@IsOptional() @IsEnum(ProjectDevelopmentStatus)
|
||||||
|
status?: ProjectDevelopmentStatus;
|
||||||
|
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsString() description?: string | null;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsObject() amenities?: Record<string, unknown> | null;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsString() masterPlanUrl?: string | null;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsString() minPrice?: string | null;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsString() maxPrice?: string | null;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsObject() pricePerM2Range?: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsNumber() @Type(() => Number) @Min(0)
|
||||||
|
totalArea?: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsNumber() @Type(() => Number) buildingCount?: number | null;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsNumber() @Type(() => Number) floorCount?: number | null;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsObject() unitTypes?: Record<string, unknown> | null;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsArray() media?: Record<string, unknown>[] | null;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsArray() documents?: Record<string, unknown>[] | null;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsArray() @IsString({ each: true }) tags?: string[];
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsBoolean() isVerified?: boolean;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsDateString() startDate?: string | null;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsDateString() completionDate?: string | null;
|
||||||
|
}
|
||||||
24
apps/api/src/modules/projects/projects.module.ts
Normal file
24
apps/api/src/modules/projects/projects.module.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CqrsModule } from '@nestjs/cqrs';
|
||||||
|
import { CreateProjectHandler } from './application/commands/create-project/create-project.handler';
|
||||||
|
import { UpdateProjectHandler } from './application/commands/update-project/update-project.handler';
|
||||||
|
import { GetProjectHandler } from './application/queries/get-project/get-project.handler';
|
||||||
|
import { ListProjectsHandler } from './application/queries/list-projects/list-projects.handler';
|
||||||
|
import { PROJECT_REPOSITORY } from './domain/repositories/project-development.repository';
|
||||||
|
import { PrismaProjectDevelopmentRepository } from './infrastructure/repositories/prisma-project-development.repository';
|
||||||
|
import { ProjectsController } from './presentation/controllers/projects.controller';
|
||||||
|
|
||||||
|
const CommandHandlers = [CreateProjectHandler, UpdateProjectHandler];
|
||||||
|
const QueryHandlers = [GetProjectHandler, ListProjectsHandler];
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [CqrsModule],
|
||||||
|
controllers: [ProjectsController],
|
||||||
|
providers: [
|
||||||
|
{ provide: PROJECT_REPOSITORY, useClass: PrismaProjectDevelopmentRepository },
|
||||||
|
...CommandHandlers,
|
||||||
|
...QueryHandlers,
|
||||||
|
],
|
||||||
|
exports: [PROJECT_REPOSITORY],
|
||||||
|
})
|
||||||
|
export class ProjectsModule {}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { PuppeteerPdfGeneratorService } from '../services/pdf-generator.service';
|
||||||
|
|
||||||
|
const { mockPdf, mockSetContent, mockNewPage, mockClose } = vi.hoisted(() => {
|
||||||
|
const mockPdf = vi.fn();
|
||||||
|
const mockSetContent = vi.fn();
|
||||||
|
const mockNewPage = vi.fn().mockResolvedValue({
|
||||||
|
setContent: mockSetContent,
|
||||||
|
pdf: mockPdf,
|
||||||
|
});
|
||||||
|
const mockClose = vi.fn();
|
||||||
|
return { mockPdf, mockSetContent, mockNewPage, mockClose };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('puppeteer', () => ({
|
||||||
|
default: {
|
||||||
|
launch: vi.fn().mockResolvedValue({
|
||||||
|
newPage: mockNewPage,
|
||||||
|
close: mockClose,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('fs', () => ({
|
||||||
|
writeFileSync: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('PuppeteerPdfGeneratorService', () => {
|
||||||
|
let service: PuppeteerPdfGeneratorService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
service = new PuppeteerPdfGeneratorService();
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildContent = (overrides: Record<string, unknown> = {}): Record<string, unknown> => ({
|
||||||
|
reportType: 'INDUSTRIAL_LOCATION',
|
||||||
|
province: 'Bình Dương',
|
||||||
|
generatedAt: '2026-04-01T00:00:00.000Z',
|
||||||
|
sections: {
|
||||||
|
executive_summary: {
|
||||||
|
title: 'Tóm tắt',
|
||||||
|
content: 'Báo cáo tổng quan thị trường KCN Bình Dương.',
|
||||||
|
},
|
||||||
|
economic_indicators: {
|
||||||
|
title: 'Chỉ số kinh tế',
|
||||||
|
data: {
|
||||||
|
gdp: [
|
||||||
|
{ period: '2024', value: 150000, unit: 'tỷ VND' },
|
||||||
|
{ period: '2025', value: 165000, unit: 'tỷ VND' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
charts: {
|
||||||
|
gdp_trend: [
|
||||||
|
{ period: '2024', value: 150000, unit: 'tỷ VND' },
|
||||||
|
{ period: '2025', value: 165000, unit: 'tỷ VND' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
infrastructure: {
|
||||||
|
title: 'Hạ tầng',
|
||||||
|
projects: [
|
||||||
|
{ name: 'KCN VSIP III', category: 'industrial_park', status: 'under_construction', investmentVND: 5000000000000 },
|
||||||
|
],
|
||||||
|
summary: {
|
||||||
|
total: 1,
|
||||||
|
byCategory: { industrial_park: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates a PDF and returns the file path', async () => {
|
||||||
|
const pdfBuffer = Buffer.from('fake-pdf-content');
|
||||||
|
mockPdf.mockResolvedValue(pdfBuffer);
|
||||||
|
|
||||||
|
const result = await service.generatePdf('report-123', buildContent());
|
||||||
|
|
||||||
|
expect(result).toMatch(/goodgo-report-report-123-\d+\.pdf$/);
|
||||||
|
expect(mockNewPage).toHaveBeenCalledOnce();
|
||||||
|
expect(mockSetContent).toHaveBeenCalledOnce();
|
||||||
|
expect(mockPdf).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
format: 'A4',
|
||||||
|
printBackground: true,
|
||||||
|
displayHeaderFooter: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets page content with waitUntil networkidle0', async () => {
|
||||||
|
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||||
|
|
||||||
|
await service.generatePdf('report-456', buildContent());
|
||||||
|
|
||||||
|
expect(mockSetContent).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
{ waitUntil: 'networkidle0' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes cover page with title, type label, and date', async () => {
|
||||||
|
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||||
|
|
||||||
|
await service.generatePdf('report-789', buildContent());
|
||||||
|
|
||||||
|
const html = mockSetContent.mock.calls[0][0] as string;
|
||||||
|
expect(html).toContain('Bình Dương');
|
||||||
|
expect(html).toContain('Vị trí khu công nghiệp');
|
||||||
|
expect(html).toContain('class="cover"');
|
||||||
|
expect(html).toContain('GoodGo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes table of contents', async () => {
|
||||||
|
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||||
|
|
||||||
|
await service.generatePdf('report-toc', buildContent());
|
||||||
|
|
||||||
|
const html = mockSetContent.mock.calls[0][0] as string;
|
||||||
|
expect(html).toContain('Mục lục');
|
||||||
|
expect(html).toContain('class="toc"');
|
||||||
|
expect(html).toContain('Tóm tắt');
|
||||||
|
expect(html).toContain('Chỉ số kinh tế');
|
||||||
|
expect(html).toContain('Hạ tầng');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders SVG charts from chart data', async () => {
|
||||||
|
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||||
|
|
||||||
|
await service.generatePdf('report-charts', buildContent());
|
||||||
|
|
||||||
|
const html = mockSetContent.mock.calls[0][0] as string;
|
||||||
|
expect(html).toContain('<svg');
|
||||||
|
expect(html).toContain('chart-container');
|
||||||
|
expect(html).toContain('Gdp Trend');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders data tables', async () => {
|
||||||
|
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||||
|
|
||||||
|
await service.generatePdf('report-tables', buildContent());
|
||||||
|
|
||||||
|
const html = mockSetContent.mock.calls[0][0] as string;
|
||||||
|
expect(html).toContain('data-table');
|
||||||
|
expect(html).toContain('Kỳ');
|
||||||
|
expect(html).toContain('Giá trị');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders infrastructure projects table', async () => {
|
||||||
|
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||||
|
|
||||||
|
await service.generatePdf('report-infra', buildContent());
|
||||||
|
|
||||||
|
const html = mockSetContent.mock.calls[0][0] as string;
|
||||||
|
expect(html).toContain('KCN VSIP III');
|
||||||
|
expect(html).toContain('Dự án');
|
||||||
|
expect(html).toContain('Vốn đầu tư (VND)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes Be Vietnam Pro font import', async () => {
|
||||||
|
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||||
|
|
||||||
|
await service.generatePdf('report-font', buildContent());
|
||||||
|
|
||||||
|
const html = mockSetContent.mock.calls[0][0] as string;
|
||||||
|
expect(html).toContain('Be+Vietnam+Pro');
|
||||||
|
expect(html).toContain("font-family: 'Be Vietnam Pro'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes methodology and disclaimer section', async () => {
|
||||||
|
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||||
|
|
||||||
|
await service.generatePdf('report-method', buildContent());
|
||||||
|
|
||||||
|
const html = mockSetContent.mock.calls[0][0] as string;
|
||||||
|
expect(html).toContain('Phương pháp');
|
||||||
|
expect(html).toContain('Miễn trừ trách nhiệm');
|
||||||
|
expect(html).toContain('research@goodgo.vn');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes page number footer', async () => {
|
||||||
|
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||||
|
|
||||||
|
await service.generatePdf('report-footer', buildContent());
|
||||||
|
|
||||||
|
const pdfOptions = mockPdf.mock.calls[0][0];
|
||||||
|
expect(pdfOptions.footerTemplate).toContain('pageNumber');
|
||||||
|
expect(pdfOptions.footerTemplate).toContain('totalPages');
|
||||||
|
expect(pdfOptions.footerTemplate).toContain('GoodGo AI Report');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes HTML in user-provided content', async () => {
|
||||||
|
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||||
|
|
||||||
|
const content = buildContent({
|
||||||
|
province: '<script>alert("xss")</script>',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.generatePdf('report-xss', content);
|
||||||
|
|
||||||
|
const html = mockSetContent.mock.calls[0][0] as string;
|
||||||
|
expect(html).not.toContain('<script>');
|
||||||
|
expect(html).toContain('<script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes browser even if PDF generation fails', async () => {
|
||||||
|
mockPdf.mockRejectedValue(new Error('Render failed'));
|
||||||
|
|
||||||
|
await expect(service.generatePdf('report-fail', buildContent())).rejects.toThrow('Render failed');
|
||||||
|
|
||||||
|
expect(mockClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty sections gracefully', async () => {
|
||||||
|
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||||
|
|
||||||
|
const content = buildContent({ sections: {} });
|
||||||
|
await service.generatePdf('report-empty', content);
|
||||||
|
|
||||||
|
const html = mockSetContent.mock.calls[0][0] as string;
|
||||||
|
expect(html).toContain('class="cover"');
|
||||||
|
expect(html).toContain('class="toc"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing content fields with defaults', async () => {
|
||||||
|
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||||
|
|
||||||
|
await service.generatePdf('report-defaults', {});
|
||||||
|
|
||||||
|
const html = mockSetContent.mock.calls[0][0] as string;
|
||||||
|
expect(html).toContain('class="cover"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses A4 format with 2cm margins', async () => {
|
||||||
|
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||||
|
|
||||||
|
await service.generatePdf('report-margins', buildContent());
|
||||||
|
|
||||||
|
expect(mockPdf).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
format: 'A4',
|
||||||
|
margin: { top: '2cm', right: '2cm', bottom: '2cm', left: '2cm' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { ReportEntity } from '../../domain/entities/report.entity';
|
||||||
|
import { ReportStatus } from '../../domain/enums/report-status.enum';
|
||||||
|
import { ReportType } from '../../domain/enums/report-type.enum';
|
||||||
|
import { type IReportRepository } from '../../domain/repositories/report.repository';
|
||||||
|
import { type IAINarrativeService } from '../../domain/services/ai-narrative.service';
|
||||||
|
import { type IInfrastructureDataService } from '../../domain/services/infrastructure-data.service';
|
||||||
|
import { type IMacroDataService } from '../../domain/services/macro-data.service';
|
||||||
|
import { type IPdfGeneratorService } from '../../domain/services/pdf-generator.service';
|
||||||
|
import { type IPdfStorageService } from '../../domain/services/pdf-storage.service';
|
||||||
|
import { ReportGenerationProcessor } from '../services/report-generation.processor';
|
||||||
|
|
||||||
|
// Mock fs
|
||||||
|
vi.mock('fs', () => ({
|
||||||
|
readFileSync: vi.fn().mockReturnValue(Buffer.from('fake-pdf')),
|
||||||
|
unlinkSync: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ReportGenerationProcessor', () => {
|
||||||
|
let processor: ReportGenerationProcessor;
|
||||||
|
let mockReportRepo: { [K in keyof IReportRepository]: ReturnType<typeof vi.fn> };
|
||||||
|
let mockMacroData: { [K in keyof IMacroDataService]: ReturnType<typeof vi.fn> };
|
||||||
|
let mockInfraData: { [K in keyof IInfrastructureDataService]: ReturnType<typeof vi.fn> };
|
||||||
|
let mockAINarrative: { [K in keyof IAINarrativeService]: ReturnType<typeof vi.fn> };
|
||||||
|
let mockPdfGenerator: { [K in keyof IPdfGeneratorService]: ReturnType<typeof vi.fn> };
|
||||||
|
let mockPdfStorage: { [K in keyof IPdfStorageService]: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
const createReport = (type: ReportType, params: Record<string, unknown>) =>
|
||||||
|
ReportEntity.createNew('report-1', 'user-1', type, 'Test Report', params);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
mockReportRepo = {
|
||||||
|
findById: vi.fn(),
|
||||||
|
findByUserId: vi.fn(),
|
||||||
|
save: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
countByUserInPeriod: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockMacroData = {
|
||||||
|
getByProvince: vi.fn().mockResolvedValue([
|
||||||
|
{ indicator: 'gdp', period: '2025', value: 150000, unit: 'tỷ VND' },
|
||||||
|
{ indicator: 'fdi', period: '2025', value: 5000, unit: 'triệu USD' },
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockInfraData = {
|
||||||
|
getByProvince: vi.fn().mockResolvedValue([
|
||||||
|
{ name: 'KCN VSIP', category: 'industrial_park', status: 'active', investmentVND: BigInt(5000000000000), completionDate: new Date('2024-01-01') },
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockAINarrative = {
|
||||||
|
generateNarrative: vi.fn().mockResolvedValue('AI-generated analysis text.'),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPdfGenerator = {
|
||||||
|
generatePdf: vi.fn().mockResolvedValue('/tmp/report.pdf'),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPdfStorage = {
|
||||||
|
uploadPdf: vi.fn().mockResolvedValue('https://storage.example.com/reports/report-1.pdf'),
|
||||||
|
};
|
||||||
|
|
||||||
|
processor = new ReportGenerationProcessor(
|
||||||
|
mockReportRepo as any,
|
||||||
|
mockMacroData as any,
|
||||||
|
mockInfraData as any,
|
||||||
|
mockAINarrative as any,
|
||||||
|
mockPdfGenerator as any,
|
||||||
|
mockPdfStorage as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeJob = (reportId: string) => ({ data: { reportId } }) as any;
|
||||||
|
|
||||||
|
it('skips processing when report is not found', async () => {
|
||||||
|
mockReportRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await processor.process(makeJob('nonexistent'));
|
||||||
|
|
||||||
|
expect(mockPdfGenerator.generatePdf).not.toHaveBeenCalled();
|
||||||
|
expect(mockReportRepo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('INDUSTRIAL_LOCATION report', () => {
|
||||||
|
it('fetches macro data and infra projects, generates narratives, creates PDF', async () => {
|
||||||
|
const report = createReport(ReportType.INDUSTRIAL_LOCATION, { province: 'Bình Dương' });
|
||||||
|
mockReportRepo.findById.mockResolvedValue(report);
|
||||||
|
|
||||||
|
await processor.process(makeJob('report-1'));
|
||||||
|
|
||||||
|
// Fetches data
|
||||||
|
expect(mockMacroData.getByProvince).toHaveBeenCalledWith(
|
||||||
|
'Bình Dương',
|
||||||
|
expect.arrayContaining(['gdp', 'fdi', 'population']),
|
||||||
|
);
|
||||||
|
expect(mockInfraData.getByProvince).toHaveBeenCalledWith('Bình Dương');
|
||||||
|
|
||||||
|
// Generates AI narratives for 4 sections
|
||||||
|
expect(mockAINarrative.generateNarrative).toHaveBeenCalledTimes(4);
|
||||||
|
expect(mockAINarrative.generateNarrative).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ sectionKey: 'executive_summary' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generates and uploads PDF
|
||||||
|
expect(mockPdfGenerator.generatePdf).toHaveBeenCalledOnce();
|
||||||
|
expect(mockPdfStorage.uploadPdf).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
// Marks report as ready with pdfUrl
|
||||||
|
expect(mockReportRepo.update).toHaveBeenCalledOnce();
|
||||||
|
expect(report.status).toBe(ReportStatus.READY);
|
||||||
|
expect(report.pdfUrl).toBe('https://storage.example.com/reports/report-1.pdf');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RESIDENTIAL_MARKET report', () => {
|
||||||
|
it('fetches macro data and generates narratives for market sections', async () => {
|
||||||
|
const report = createReport(ReportType.RESIDENTIAL_MARKET, { city: 'TP.HCM', period: 'Q1-2026' });
|
||||||
|
mockReportRepo.findById.mockResolvedValue(report);
|
||||||
|
|
||||||
|
await processor.process(makeJob('report-1'));
|
||||||
|
|
||||||
|
expect(mockMacroData.getByProvince).toHaveBeenCalledWith(
|
||||||
|
'TP.HCM',
|
||||||
|
expect.arrayContaining(['gdp', 'cpi', 'mortgage_rate']),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6 narrative sections for residential market
|
||||||
|
expect(mockAINarrative.generateNarrative).toHaveBeenCalledTimes(6);
|
||||||
|
|
||||||
|
expect(report.status).toBe(ReportStatus.READY);
|
||||||
|
expect(report.content).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DISTRICT_ANALYSIS report', () => {
|
||||||
|
it('generates narratives for district sections', async () => {
|
||||||
|
const report = createReport(ReportType.DISTRICT_ANALYSIS, { city: 'TP.HCM', district: 'Quận 2' });
|
||||||
|
mockReportRepo.findById.mockResolvedValue(report);
|
||||||
|
|
||||||
|
await processor.process(makeJob('report-1'));
|
||||||
|
|
||||||
|
// 5 narrative sections for district analysis
|
||||||
|
expect(mockAINarrative.generateNarrative).toHaveBeenCalledTimes(5);
|
||||||
|
|
||||||
|
expect(report.status).toBe(ReportStatus.READY);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generic report type', () => {
|
||||||
|
it('generates a single executive summary for unknown report types', async () => {
|
||||||
|
const report = createReport(ReportType.PORTFOLIO, { assets: ['prop-1'] });
|
||||||
|
mockReportRepo.findById.mockResolvedValue(report);
|
||||||
|
|
||||||
|
await processor.process(makeJob('report-1'));
|
||||||
|
|
||||||
|
expect(mockAINarrative.generateNarrative).toHaveBeenCalledOnce();
|
||||||
|
expect(mockAINarrative.generateNarrative).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ sectionKey: 'executive_summary' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(report.status).toBe(ReportStatus.READY);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PDF generation failure', () => {
|
||||||
|
it('completes report without PDF when PDF generation fails', async () => {
|
||||||
|
const report = createReport(ReportType.DISTRICT_ANALYSIS, { city: 'Hà Nội', district: 'Hoàn Kiếm' });
|
||||||
|
mockReportRepo.findById.mockResolvedValue(report);
|
||||||
|
mockPdfGenerator.generatePdf.mockRejectedValue(new Error('Puppeteer crashed'));
|
||||||
|
|
||||||
|
await processor.process(makeJob('report-1'));
|
||||||
|
|
||||||
|
// Report should still be marked ready, but without pdfUrl
|
||||||
|
expect(report.status).toBe(ReportStatus.READY);
|
||||||
|
expect(report.pdfUrl).toBeNull();
|
||||||
|
expect(mockReportRepo.update).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('content generation failure', () => {
|
||||||
|
it('marks report as failed when narrative generation throws', async () => {
|
||||||
|
const report = createReport(ReportType.INDUSTRIAL_LOCATION, { province: 'Đồng Nai' });
|
||||||
|
mockReportRepo.findById.mockResolvedValue(report);
|
||||||
|
mockAINarrative.generateNarrative.mockRejectedValue(new Error('AI service unavailable'));
|
||||||
|
|
||||||
|
await expect(processor.process(makeJob('report-1'))).rejects.toThrow('AI service unavailable');
|
||||||
|
|
||||||
|
expect(report.status).toBe(ReportStatus.FAILED);
|
||||||
|
expect(report.errorMsg).toBe('AI service unavailable');
|
||||||
|
expect(mockReportRepo.update).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans up temp PDF file after upload', async () => {
|
||||||
|
const fs = await import('fs');
|
||||||
|
const report = createReport(ReportType.DISTRICT_ANALYSIS, { city: 'TP.HCM', district: 'Quận 1' });
|
||||||
|
mockReportRepo.findById.mockResolvedValue(report);
|
||||||
|
mockPdfGenerator.generatePdf.mockResolvedValue('/tmp/goodgo-report-1.pdf');
|
||||||
|
|
||||||
|
await processor.process(makeJob('report-1'));
|
||||||
|
|
||||||
|
expect(fs.readFileSync).toHaveBeenCalledWith('/tmp/goodgo-report-1.pdf');
|
||||||
|
expect(fs.unlinkSync).toHaveBeenCalledWith('/tmp/goodgo-report-1.pdf');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -107,4 +107,41 @@ describe('SearchPropertiesHandler', () => {
|
|||||||
const searchCall = mockSearchRepo.search.mock.calls[0]![0];
|
const searchCall = mockSearchRepo.search.mock.calls[0]![0];
|
||||||
expect(searchCall.filterBy).toContain('areaM2:<=200');
|
expect(searchCall.filterBy).toContain('areaM2:<=200');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies featured=true filter as isFeatured:=1', async () => {
|
||||||
|
mockSearchRepo.search.mockResolvedValue(createMockSearchResult());
|
||||||
|
|
||||||
|
const query = new SearchPropertiesQuery(
|
||||||
|
undefined, undefined, undefined, undefined, undefined,
|
||||||
|
undefined, undefined, undefined, undefined, undefined,
|
||||||
|
undefined, undefined, undefined, true,
|
||||||
|
);
|
||||||
|
await handler.execute(query);
|
||||||
|
|
||||||
|
const searchCall = mockSearchRepo.search.mock.calls[0]![0];
|
||||||
|
expect(searchCall.filterBy).toContain('isFeatured:=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies featured=false filter as isFeatured:=0', async () => {
|
||||||
|
mockSearchRepo.search.mockResolvedValue(createMockSearchResult());
|
||||||
|
|
||||||
|
const query = new SearchPropertiesQuery(
|
||||||
|
undefined, undefined, undefined, undefined, undefined,
|
||||||
|
undefined, undefined, undefined, undefined, undefined,
|
||||||
|
undefined, undefined, undefined, false,
|
||||||
|
);
|
||||||
|
await handler.execute(query);
|
||||||
|
|
||||||
|
const searchCall = mockSearchRepo.search.mock.calls[0]![0];
|
||||||
|
expect(searchCall.filterBy).toContain('isFeatured:=0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits isFeatured filter when featured is undefined', async () => {
|
||||||
|
mockSearchRepo.search.mockResolvedValue(createMockSearchResult());
|
||||||
|
|
||||||
|
await handler.execute(new SearchPropertiesQuery('anything'));
|
||||||
|
|
||||||
|
const searchCall = mockSearchRepo.search.mock.calls[0]![0];
|
||||||
|
expect(searchCall.filterBy).not.toContain('isFeatured');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ export class SearchPropertiesHandler implements IQueryHandler<SearchPropertiesQu
|
|||||||
if (query.city) {
|
if (query.city) {
|
||||||
filters.push(`city:=${query.city}`);
|
filters.push(`city:=${query.city}`);
|
||||||
}
|
}
|
||||||
|
if (query.featured === true) {
|
||||||
|
filters.push(`isFeatured:=1`);
|
||||||
|
} else if (query.featured === false) {
|
||||||
|
filters.push(`isFeatured:=0`);
|
||||||
|
}
|
||||||
|
|
||||||
const searchParams = {
|
const searchParams = {
|
||||||
query: query.query,
|
query: query.query,
|
||||||
@@ -73,6 +78,7 @@ export class SearchPropertiesHandler implements IQueryHandler<SearchPropertiesQu
|
|||||||
query.areaMax,
|
query.areaMax,
|
||||||
query.bedrooms,
|
query.bedrooms,
|
||||||
query.sortBy,
|
query.sortBy,
|
||||||
|
query.featured === undefined ? undefined : String(query.featured),
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.cache.getOrSet(
|
return this.cache.getOrSet(
|
||||||
|
|||||||
@@ -13,5 +13,6 @@ export class SearchPropertiesQuery {
|
|||||||
public readonly sortBy?: string,
|
public readonly sortBy?: string,
|
||||||
public readonly page?: number,
|
public readonly page?: number,
|
||||||
public readonly perPage?: number,
|
public readonly perPage?: number,
|
||||||
|
public readonly featured?: boolean,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export class SearchController {
|
|||||||
dto.sortBy,
|
dto.sortBy,
|
||||||
dto.page,
|
dto.page,
|
||||||
dto.perPage,
|
dto.perPage,
|
||||||
|
dto.featured,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Transform, Type } from 'class-transformer';
|
import { Transform, Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
|
IsBoolean,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
@@ -78,6 +79,22 @@ export class SearchPropertiesDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
city?: string;
|
city?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Chỉ trả về tin đang được đẩy nổi bật (featured)',
|
||||||
|
example: true,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => {
|
||||||
|
if (value === undefined || value === null || value === '') return undefined;
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
const normalized = String(value).toLowerCase();
|
||||||
|
if (normalized === 'true' || normalized === '1') return true;
|
||||||
|
if (normalized === 'false' || normalized === '0') return false;
|
||||||
|
return value;
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
featured?: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Sort order', enum: SortByOption, example: SortByOption.PRICE_ASC })
|
@ApiPropertyOptional({ description: 'Sort order', enum: SortByOption, example: SortByOption.PRICE_ASC })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(SortByOption)
|
@IsEnum(SortByOption)
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
const hoisted = vi.hoisted(() => ({
|
||||||
|
redisConnect: vi.fn(),
|
||||||
|
redisQuit: vi.fn(),
|
||||||
|
createAdapterMock: vi.fn(() => Symbol('adapter')),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('ioredis', () => {
|
||||||
|
class FakeRedis {
|
||||||
|
connect = hoisted.redisConnect;
|
||||||
|
quit = hoisted.redisQuit;
|
||||||
|
duplicate() {
|
||||||
|
return new FakeRedis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { default: FakeRedis };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@socket.io/redis-adapter', () => ({
|
||||||
|
createAdapter: hoisted.createAdapterMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { RedisIoAdapter } from '../redis-io.adapter';
|
||||||
|
|
||||||
|
function createApp(): unknown {
|
||||||
|
return {
|
||||||
|
get: () => ({
|
||||||
|
log: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
}),
|
||||||
|
getHttpServer: () => undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RedisIoAdapter', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
hoisted.redisConnect.mockReset();
|
||||||
|
hoisted.redisQuit.mockReset();
|
||||||
|
hoisted.createAdapterMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('connects pub/sub clients and registers the adapter on the server', async () => {
|
||||||
|
hoisted.redisConnect.mockResolvedValue(undefined);
|
||||||
|
const adapter = new RedisIoAdapter(createApp() as any);
|
||||||
|
|
||||||
|
await adapter.connectToRedis();
|
||||||
|
|
||||||
|
expect(hoisted.redisConnect).toHaveBeenCalledTimes(2);
|
||||||
|
expect(hoisted.createAdapterMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const adapterFn = vi.fn();
|
||||||
|
const fakeServer = { adapter: adapterFn };
|
||||||
|
const superProto = Object.getPrototypeOf(Object.getPrototypeOf(adapter)) as object;
|
||||||
|
vi.spyOn(superProto, 'createIOServer').mockReturnValue(fakeServer);
|
||||||
|
|
||||||
|
const result = adapter.createIOServer(3001);
|
||||||
|
|
||||||
|
expect(adapterFn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result).toBe(fakeServer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back silently when Redis pub/sub connect fails', async () => {
|
||||||
|
hoisted.redisConnect.mockRejectedValue(new Error('connection refused'));
|
||||||
|
const adapter = new RedisIoAdapter(createApp() as any);
|
||||||
|
|
||||||
|
await adapter.connectToRedis();
|
||||||
|
|
||||||
|
expect(hoisted.createAdapterMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const fakeServer = { adapter: vi.fn() };
|
||||||
|
const superProto = Object.getPrototypeOf(Object.getPrototypeOf(adapter)) as object;
|
||||||
|
vi.spyOn(superProto, 'createIOServer').mockReturnValue(fakeServer);
|
||||||
|
|
||||||
|
adapter.createIOServer(3001);
|
||||||
|
|
||||||
|
expect(fakeServer.adapter).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('close() quits pub/sub clients', async () => {
|
||||||
|
hoisted.redisConnect.mockResolvedValue(undefined);
|
||||||
|
hoisted.redisQuit.mockResolvedValue(undefined);
|
||||||
|
const adapter = new RedisIoAdapter(createApp() as any);
|
||||||
|
await adapter.connectToRedis();
|
||||||
|
|
||||||
|
await adapter.close();
|
||||||
|
|
||||||
|
expect(hoisted.redisQuit).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ export {
|
|||||||
export { createEncryptionExtension } from './encryption-middleware';
|
export { createEncryptionExtension } from './encryption-middleware';
|
||||||
export { PrismaService } from './prisma.service';
|
export { PrismaService } from './prisma.service';
|
||||||
export { RedisService } from './redis.service';
|
export { RedisService } from './redis.service';
|
||||||
|
export { RedisIoAdapter } from './redis-io.adapter';
|
||||||
export { CacheService, CachePrefix, CacheTTL } from './cache.service';
|
export { CacheService, CachePrefix, CacheTTL } from './cache.service';
|
||||||
export { LoggerService } from './logger.service';
|
export { LoggerService } from './logger.service';
|
||||||
export { EventBusService } from './event-bus.service';
|
export { EventBusService } from './event-bus.service';
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { INestApplicationContext } from '@nestjs/common';
|
||||||
|
import { IoAdapter } from '@nestjs/platform-socket.io';
|
||||||
|
import { createAdapter } from '@socket.io/redis-adapter';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import type { ServerOptions } from 'socket.io';
|
||||||
|
import { LoggerService } from './logger.service';
|
||||||
|
|
||||||
|
const CONTEXT = 'RedisIoAdapter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Socket.IO adapter backed by Redis pub/sub so WebSocket broadcasts
|
||||||
|
* fan out across every API instance.
|
||||||
|
*
|
||||||
|
* Falls back to the in-memory IoAdapter when Redis cannot be reached,
|
||||||
|
* so local dev without Redis and single-node deployments still work.
|
||||||
|
*/
|
||||||
|
export class RedisIoAdapter extends IoAdapter {
|
||||||
|
private adapterConstructor: ReturnType<typeof createAdapter> | null = null;
|
||||||
|
private pubClient: Redis | null = null;
|
||||||
|
private subClient: Redis | null = null;
|
||||||
|
private readonly logger: LoggerService;
|
||||||
|
|
||||||
|
constructor(app: INestApplicationContext) {
|
||||||
|
super(app);
|
||||||
|
this.logger = app.get(LoggerService);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectToRedis(): Promise<void> {
|
||||||
|
const host = process.env['REDIS_HOST'] ?? 'localhost';
|
||||||
|
const port = Number(process.env['REDIS_PORT'] ?? 6379);
|
||||||
|
const password = process.env['REDIS_PASSWORD'] ?? undefined;
|
||||||
|
|
||||||
|
const pub = new Redis({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
password,
|
||||||
|
lazyConnect: true,
|
||||||
|
enableReadyCheck: false,
|
||||||
|
maxRetriesPerRequest: 1,
|
||||||
|
retryStrategy: (times) => Math.min(times * 1000, 5000),
|
||||||
|
});
|
||||||
|
const sub = pub.duplicate();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([pub.connect(), sub.connect()]);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Redis pub/sub unavailable — falling back to in-memory adapter: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
CONTEXT,
|
||||||
|
);
|
||||||
|
await Promise.allSettled([pub.quit(), sub.quit()]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pubClient = pub;
|
||||||
|
this.subClient = sub;
|
||||||
|
this.adapterConstructor = createAdapter(pub, sub);
|
||||||
|
this.logger.log(
|
||||||
|
`Redis pub/sub adapter connected (${host}:${port})`,
|
||||||
|
CONTEXT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
override createIOServer(port: number, options?: ServerOptions): unknown {
|
||||||
|
const server = super.createIOServer(port, options) as {
|
||||||
|
adapter: (constructor: unknown) => void;
|
||||||
|
};
|
||||||
|
if (this.adapterConstructor) {
|
||||||
|
server.adapter(this.adapterConstructor);
|
||||||
|
}
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
override async close(): Promise<void> {
|
||||||
|
await Promise.allSettled([
|
||||||
|
this.pubClient?.quit(),
|
||||||
|
this.subClient?.quit(),
|
||||||
|
]);
|
||||||
|
this.pubClient = null;
|
||||||
|
this.subClient = null;
|
||||||
|
this.adapterConstructor = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ const METRIC_TO_PLAN_FIELD: Record<string, keyof Plan> = {
|
|||||||
searches_saved: 'maxSavedSearches',
|
searches_saved: 'maxSavedSearches',
|
||||||
analytics_queries: 'maxAnalyticsQueries',
|
analytics_queries: 'maxAnalyticsQueries',
|
||||||
media_uploads: 'maxMediaUploads',
|
media_uploads: 'maxMediaUploads',
|
||||||
|
featured_listings_promoted: 'featuredListingsQuota',
|
||||||
};
|
};
|
||||||
|
|
||||||
@QueryHandler(CheckQuotaQuery)
|
@QueryHandler(CheckQuotaQuery)
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { type BankTransferConfirmedEvent } from '@modules/payments';
|
||||||
|
import { type LoggerService, type PrismaService } from '@modules/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles subscription activation once a bank-transfer payment is confirmed.
|
||||||
|
*
|
||||||
|
* A bank-transfer payment whose `type === 'SUBSCRIPTION'` represents a
|
||||||
|
* pending subscription activation: the user transferred funds offline and
|
||||||
|
* an admin reconciled the payment.
|
||||||
|
*
|
||||||
|
* We extend the user's current subscription period (or mark it active) so
|
||||||
|
* the user regains access immediately after confirmation. Plan selection
|
||||||
|
* happens upstream during payment creation; this listener is the
|
||||||
|
* side-effect hook that flips the subscription status.
|
||||||
|
*
|
||||||
|
* NOTE: Intentionally defensive — if no subscription exists yet the event
|
||||||
|
* is logged and skipped; downstream processes (CS or renewal cron) pick it up.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class BankTransferSubscriptionActivationHandler {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@OnEvent('payment.bank_transfer_confirmed', { async: true })
|
||||||
|
async handle(event: BankTransferConfirmedEvent): Promise<void> {
|
||||||
|
if (event.type !== 'SUBSCRIPTION') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const subscription = await this.prisma.subscription.findFirst({
|
||||||
|
where: { userId: event.userId },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Bank transfer confirmed for userId=${event.userId} but no subscription found to activate — manual CS review required (paymentId=${event.aggregateId})`,
|
||||||
|
'BankTransferSubscriptionActivationHandler',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const baseDate =
|
||||||
|
subscription.currentPeriodEnd > now ? subscription.currentPeriodEnd : now;
|
||||||
|
|
||||||
|
// Default to 30-day extension; renewal command handles more granular math
|
||||||
|
const nextPeriodEnd = new Date(
|
||||||
|
baseDate.getTime() + 30 * 24 * 60 * 60 * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.prisma.subscription.update({
|
||||||
|
where: { id: subscription.id },
|
||||||
|
data: {
|
||||||
|
status: 'ACTIVE',
|
||||||
|
currentPeriodEnd: nextPeriodEnd,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Subscription activated via bank transfer: subscriptionId=${subscription.id}, userId=${event.userId}, paymentId=${event.aggregateId}, periodEnd=${nextPeriodEnd.toISOString()}`,
|
||||||
|
'BankTransferSubscriptionActivationHandler',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Never break the confirm flow — log and let ops replay
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to activate subscription on bank transfer confirmation: paymentId=${event.aggregateId}, userId=${event.userId}`,
|
||||||
|
error instanceof Error ? error.stack : String(error),
|
||||||
|
'BankTransferSubscriptionActivationHandler',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export class GenerateTransferUploadUrlsCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly sellerId: string,
|
||||||
|
public readonly listingId: string | null,
|
||||||
|
public readonly files: { fileName: string; mimeType: string }[],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Inject, Logger } from '@nestjs/common';
|
||||||
|
import { type CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
|
import { CommandHandler as CqrsCommandHandler } from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
MEDIA_STORAGE_SERVICE,
|
||||||
|
type IMediaStorageService,
|
||||||
|
} from '@modules/listings/infrastructure/services/media-storage.service';
|
||||||
|
import { GenerateTransferUploadUrlsCommand } from './generate-transfer-upload-urls.command';
|
||||||
|
|
||||||
|
export interface TransferUploadUrlResult {
|
||||||
|
uploadUrl: string;
|
||||||
|
objectKey: string;
|
||||||
|
publicUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@CqrsCommandHandler(GenerateTransferUploadUrlsCommand)
|
||||||
|
export class GenerateTransferUploadUrlsHandler
|
||||||
|
implements ICommandHandler<GenerateTransferUploadUrlsCommand>
|
||||||
|
{
|
||||||
|
private readonly logger = new Logger(GenerateTransferUploadUrlsHandler.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(MEDIA_STORAGE_SERVICE)
|
||||||
|
private readonly storage: IMediaStorageService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: GenerateTransferUploadUrlsCommand): Promise<TransferUploadUrlResult[]> {
|
||||||
|
const folder = command.listingId
|
||||||
|
? `transfer/${command.sellerId}/${command.listingId}`
|
||||||
|
: `transfer/${command.sellerId}/draft`;
|
||||||
|
|
||||||
|
const results: TransferUploadUrlResult[] = [];
|
||||||
|
|
||||||
|
for (const file of command.files.slice(0, 10)) {
|
||||||
|
try {
|
||||||
|
const result = await this.storage.generatePresignedUpload(
|
||||||
|
folder,
|
||||||
|
file.fileName,
|
||||||
|
file.mimeType,
|
||||||
|
600, // 10 min expiry
|
||||||
|
);
|
||||||
|
results.push(result);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to generate upload URL for ${file.fileName}: ${err instanceof Error ? err.message : 'Unknown'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { ModerateTransferListingCommand } from './moderate-transfer-listing.command';
|
||||||
|
export { ModerateTransferListingHandler } from './moderate-transfer-listing.handler';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export class ModerateTransferListingCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly listingId: string,
|
||||||
|
public readonly moderatorId: string,
|
||||||
|
public readonly action: 'approve' | 'reject',
|
||||||
|
public readonly moderationScore?: number,
|
||||||
|
public readonly notes?: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Inject } from '@nestjs/common';
|
||||||
|
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
|
||||||
|
import { type EventBusService, NotFoundException } from '@modules/shared';
|
||||||
|
import { TransferListingUpdatedEvent } from '../../../domain/events';
|
||||||
|
import {
|
||||||
|
TRANSFER_LISTING_REPOSITORY,
|
||||||
|
type ITransferListingRepository,
|
||||||
|
} from '../../../domain/repositories/transfer-listing.repository';
|
||||||
|
import { ModerateTransferListingCommand } from './moderate-transfer-listing.command';
|
||||||
|
|
||||||
|
@CommandHandler(ModerateTransferListingCommand)
|
||||||
|
export class ModerateTransferListingHandler implements ICommandHandler<ModerateTransferListingCommand> {
|
||||||
|
constructor(
|
||||||
|
@Inject(TRANSFER_LISTING_REPOSITORY)
|
||||||
|
private readonly repo: ITransferListingRepository,
|
||||||
|
private readonly eventBus: EventBusService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(cmd: ModerateTransferListingCommand): Promise<{ status: string }> {
|
||||||
|
const entity = await this.repo.findById(cmd.listingId);
|
||||||
|
if (!entity) {
|
||||||
|
throw new NotFoundException('Transfer listing', cmd.listingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmd.action === 'approve') {
|
||||||
|
entity.approve(cmd.moderationScore, cmd.notes);
|
||||||
|
} else {
|
||||||
|
entity.reject(cmd.moderationScore, cmd.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.repo.update(entity);
|
||||||
|
|
||||||
|
this.eventBus.publish(new TransferListingUpdatedEvent(cmd.listingId));
|
||||||
|
|
||||||
|
return { status: entity.status };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { ListPendingTransfersQuery } from './list-pending-transfers.query';
|
||||||
|
export { ListPendingTransfersHandler } from './list-pending-transfers.handler';
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Inject } from '@nestjs/common';
|
||||||
|
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
TRANSFER_LISTING_REPOSITORY,
|
||||||
|
type ITransferListingRepository,
|
||||||
|
} from '../../../domain/repositories/transfer-listing.repository';
|
||||||
|
import { ListPendingTransfersQuery } from './list-pending-transfers.query';
|
||||||
|
|
||||||
|
@QueryHandler(ListPendingTransfersQuery)
|
||||||
|
export class ListPendingTransfersHandler implements IQueryHandler<ListPendingTransfersQuery> {
|
||||||
|
constructor(
|
||||||
|
@Inject(TRANSFER_LISTING_REPOSITORY)
|
||||||
|
private readonly repo: ITransferListingRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: ListPendingTransfersQuery) {
|
||||||
|
return this.repo.search({
|
||||||
|
status: 'PENDING_REVIEW',
|
||||||
|
page: query.page,
|
||||||
|
limit: query.limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export class ListPendingTransfersQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly page: number = 1,
|
||||||
|
public readonly limit: number = 20,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { ArrayMaxSize, ArrayMinSize, IsArray, IsMimeType, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||||
|
|
||||||
|
class UploadFileSpec {
|
||||||
|
@ApiProperty({ example: 'sofa-front.jpg' })
|
||||||
|
@IsString()
|
||||||
|
fileName!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'image/jpeg' })
|
||||||
|
@IsMimeType()
|
||||||
|
mimeType!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GenerateTransferUploadUrlsDto {
|
||||||
|
@ApiProperty({ required: false, description: 'Listing ID (null for draft uploads)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
listingId?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [UploadFileSpec], minItems: 1, maxItems: 10 })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ArrayMaxSize(10)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => UploadFileSpec)
|
||||||
|
files!: UploadFileSpec[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsIn, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class ModerateTransferListingDto {
|
||||||
|
@ApiProperty({ enum: ['approve', 'reject'], description: 'Hành động kiểm duyệt' })
|
||||||
|
@IsIn(['approve', 'reject'])
|
||||||
|
action!: 'approve' | 'reject';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Điểm kiểm duyệt (0-100)', minimum: 0, maximum: 100 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(100)
|
||||||
|
moderationScore?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Ghi chú kiểm duyệt' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
@@ -2,12 +2,17 @@
|
|||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ComparablesMap } from '@/components/valuation/comparables-map';
|
||||||
import { ComparablesTable } from '@/components/valuation/comparables-table';
|
import { ComparablesTable } from '@/components/valuation/comparables-table';
|
||||||
import { ExportPdfButton } from '@/components/valuation/export-pdf-button';
|
import { ExportPdfButton } from '@/components/valuation/export-pdf-button';
|
||||||
import { MarketContextCard } from '@/components/valuation/market-context-card';
|
import { MarketContextCard } from '@/components/valuation/market-context-card';
|
||||||
|
import { ValuationCompare } from '@/components/valuation/valuation-compare';
|
||||||
import { ValuationForm } from '@/components/valuation/valuation-form';
|
import { ValuationForm } from '@/components/valuation/valuation-form';
|
||||||
import { ValuationHistory } from '@/components/valuation/valuation-history';
|
import { ValuationHistory } from '@/components/valuation/valuation-history';
|
||||||
import { ValuationResults } from '@/components/valuation/valuation-results';
|
import { ValuationResults } from '@/components/valuation/valuation-results';
|
||||||
|
import { ValueDriversChart } from '@/components/valuation/value-drivers-chart';
|
||||||
|
import { useAvmV2Flag } from '@/lib/hooks/use-avm-v2-flag';
|
||||||
import {
|
import {
|
||||||
useValuationPredict,
|
useValuationPredict,
|
||||||
useValuationHistory,
|
useValuationHistory,
|
||||||
@@ -15,7 +20,6 @@ import {
|
|||||||
} from '@/lib/hooks/use-valuation';
|
} from '@/lib/hooks/use-valuation';
|
||||||
import type { ValuationRequest, ValuationResult } from '@/lib/valuation-api';
|
import type { ValuationRequest, ValuationResult } from '@/lib/valuation-api';
|
||||||
|
|
||||||
// Lazy-load chart component (uses Recharts, no SSR)
|
|
||||||
const ValuationHistoryChart = dynamic(
|
const ValuationHistoryChart = dynamic(
|
||||||
() =>
|
() =>
|
||||||
import('@/components/valuation/valuation-history-chart').then(
|
import('@/components/valuation/valuation-history-chart').then(
|
||||||
@@ -31,9 +35,13 @@ const ValuationHistoryChart = dynamic(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
type ViewMode = 'single' | 'compare';
|
||||||
|
|
||||||
export default function ValuationPage() {
|
export default function ValuationPage() {
|
||||||
|
const avmV2 = useAvmV2Flag();
|
||||||
const [historyPage, setHistoryPage] = useState(1);
|
const [historyPage, setHistoryPage] = useState(1);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('single');
|
||||||
|
|
||||||
const predictMutation = useValuationPredict();
|
const predictMutation = useValuationPredict();
|
||||||
const { data: historyData, isLoading: historyLoading } =
|
const { data: historyData, isLoading: historyLoading } =
|
||||||
@@ -54,15 +62,21 @@ export default function ValuationPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Page header */}
|
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<h1 className="text-2xl font-bold sm:text-3xl">Định giá AI</h1>
|
<h1 className="text-2xl font-bold sm:text-3xl">Định giá AI</h1>
|
||||||
|
{avmV2 && (
|
||||||
|
<Badge variant="success" className="text-xs" data-testid="avm-v2-badge">
|
||||||
|
AVM v2
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="mt-2 text-muted-foreground">
|
<p className="mt-2 text-muted-foreground">
|
||||||
Sử dụng AI để ước tính giá trị bất động sản dựa trên dữ liệu thị trường
|
Sử dụng AI để ước tính giá trị bất động sản dựa trên dữ liệu thị trường
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{currentResult && (
|
{currentResult && viewMode === 'single' && (
|
||||||
<ExportPdfButton
|
<ExportPdfButton
|
||||||
targetSelector="#valuation-results"
|
targetSelector="#valuation-results"
|
||||||
filename={`dinh-gia-${currentResult.id}`}
|
filename={`dinh-gia-${currentResult.id}`}
|
||||||
@@ -70,8 +84,47 @@ export default function ValuationPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{avmV2 && (
|
||||||
|
<div
|
||||||
|
className="inline-flex rounded-lg border bg-muted/40 p-1"
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Chế độ định giá"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={viewMode === 'single'}
|
||||||
|
data-testid="avm-v2-tab-single"
|
||||||
|
className={`rounded-md px-4 py-1.5 text-sm font-medium transition ${
|
||||||
|
viewMode === 'single'
|
||||||
|
? 'bg-background shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
onClick={() => setViewMode('single')}
|
||||||
|
>
|
||||||
|
Định giá đơn
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={viewMode === 'compare'}
|
||||||
|
data-testid="avm-v2-tab-compare"
|
||||||
|
className={`rounded-md px-4 py-1.5 text-sm font-medium transition ${
|
||||||
|
viewMode === 'compare'
|
||||||
|
? 'bg-background shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
onClick={() => setViewMode('compare')}
|
||||||
|
>
|
||||||
|
So sánh nhiều BĐS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === 'compare' && avmV2 ? (
|
||||||
|
<ValuationCompare />
|
||||||
|
) : (
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
{/* Form + Results (left 2 cols) */}
|
|
||||||
<div className="space-y-6 lg:col-span-2">
|
<div className="space-y-6 lg:col-span-2">
|
||||||
<ValuationForm
|
<ValuationForm
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
@@ -86,20 +139,26 @@ export default function ValuationPage() {
|
|||||||
|
|
||||||
{currentResult && (
|
{currentResult && (
|
||||||
<>
|
<>
|
||||||
{/* Main results with confidence badge + driver charts */}
|
|
||||||
<ValuationResults result={currentResult} />
|
<ValuationResults result={currentResult} />
|
||||||
|
|
||||||
{/* Comparables table (TanStack Table) */}
|
{avmV2 && currentResult.priceDrivers.length > 0 && (
|
||||||
|
<ValueDriversChart drivers={currentResult.priceDrivers} />
|
||||||
|
)}
|
||||||
|
|
||||||
{currentResult.comparables.length > 0 && (
|
{currentResult.comparables.length > 0 && (
|
||||||
<ComparablesTable comparables={currentResult.comparables} />
|
<ComparablesTable comparables={currentResult.comparables} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Market context card */}
|
{avmV2 && currentResult.comparables.length > 0 && (
|
||||||
|
<ComparablesMap
|
||||||
|
comparables={currentResult.comparables}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{currentResult.marketContext && (
|
{currentResult.marketContext && (
|
||||||
<MarketContextCard context={currentResult.marketContext} />
|
<MarketContextCard context={currentResult.marketContext} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Valuation history chart */}
|
|
||||||
{currentResult.valuationHistory &&
|
{currentResult.valuationHistory &&
|
||||||
currentResult.valuationHistory.length >= 2 && (
|
currentResult.valuationHistory.length >= 2 && (
|
||||||
<ValuationHistoryChart data={currentResult.valuationHistory} />
|
<ValuationHistoryChart data={currentResult.valuationHistory} />
|
||||||
@@ -108,7 +167,6 @@ export default function ValuationPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* History sidebar (right col) */}
|
|
||||||
<div>
|
<div>
|
||||||
<ValuationHistory
|
<ValuationHistory
|
||||||
items={historyData?.data ?? []}
|
items={historyData?.data ?? []}
|
||||||
@@ -120,6 +178,7 @@ export default function ValuationPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
346
apps/web/app/[locale]/(public)/bao-cao/[id]/page.tsx
Normal file
346
apps/web/app/[locale]/(public)/bao-cao/[id]/page.tsx
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Download,
|
||||||
|
Loader2,
|
||||||
|
AlertTriangle,
|
||||||
|
FileText,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { ReportChartsGrid } from '@/components/reports/report-chart';
|
||||||
|
import { ReportStatusBadge } from '@/components/reports/report-status-badge';
|
||||||
|
import { ReportTypeBadge } from '@/components/reports/report-type-badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Link } from '@/i18n/navigation';
|
||||||
|
import { useReport, useReportStatus } from '@/lib/hooks/use-reports';
|
||||||
|
|
||||||
|
// ─── Types for report content ──────────────────────────
|
||||||
|
|
||||||
|
interface SectionData {
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
data?: Record<string, Array<{ period: string; value: number; unit: string }>>;
|
||||||
|
charts?: Record<string, Array<{ period: string; value: number; unit: string }>>;
|
||||||
|
projects?: Array<Record<string, unknown>>;
|
||||||
|
summary?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function BaoCaoDetailPage() {
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const reportId = params.id;
|
||||||
|
|
||||||
|
const { data: report, isLoading, isError, refetch } = useReport(reportId);
|
||||||
|
|
||||||
|
// Poll status while generating
|
||||||
|
const isGenerating = report?.status === 'GENERATING';
|
||||||
|
const { data: statusData } = useReportStatus(
|
||||||
|
isGenerating ? reportId : null,
|
||||||
|
isGenerating,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refetch full report when status changes to READY
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (statusData?.status === 'READY' || statusData?.status === 'FAILED') {
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
}, [statusData?.status, refetch]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[50vh] items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !report) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl px-4 py-12 text-center">
|
||||||
|
<AlertTriangle className="mx-auto h-12 w-12 text-muted-foreground/30" />
|
||||||
|
<p className="mt-4 text-lg font-medium">Không tìm thấy báo cáo</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Báo cáo không tồn tại hoặc đã bị xóa.
|
||||||
|
</p>
|
||||||
|
<Link href="/bao-cao">
|
||||||
|
<Button variant="outline" className="mt-4 gap-2">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Quay lại danh sách
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdDate = new Date(report.createdAt).toLocaleDateString('vi-VN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
const sections = (report.content?.['sections'] as Record<string, SectionData>) ?? {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl px-4 py-6">
|
||||||
|
{/* Back link */}
|
||||||
|
<Link
|
||||||
|
href="/bao-cao"
|
||||||
|
className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
|
Danh sách báo cáo
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6 flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<ReportTypeBadge type={report.type} />
|
||||||
|
<ReportStatusBadge status={report.status} />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold md:text-3xl">{report.title}</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{createdDate}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{report.pdfUrl && (
|
||||||
|
<a href={report.pdfUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Button variant="outline" className="gap-2">
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Tải PDF
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Generating state */}
|
||||||
|
{report.status === 'GENERATING' && (
|
||||||
|
<div className="rounded-lg border bg-blue-50 p-8 text-center">
|
||||||
|
<Loader2 className="mx-auto h-10 w-10 animate-spin text-blue-500" />
|
||||||
|
<p className="mt-4 text-lg font-medium text-blue-900">
|
||||||
|
Đang tạo báo cáo...
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-blue-700">
|
||||||
|
Hệ thống AI đang phân tích dữ liệu và tạo báo cáo. Quá trình này
|
||||||
|
có thể mất 1-3 phút.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Failed state */}
|
||||||
|
{report.status === 'FAILED' && (
|
||||||
|
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-8 text-center">
|
||||||
|
<AlertTriangle className="mx-auto h-10 w-10 text-destructive" />
|
||||||
|
<p className="mt-4 text-lg font-medium text-destructive">
|
||||||
|
Tạo báo cáo thất bại
|
||||||
|
</p>
|
||||||
|
{report.errorMsg && (
|
||||||
|
<p className="mt-1 text-sm text-destructive/80">
|
||||||
|
{report.errorMsg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Link href="/bao-cao/tao-moi">
|
||||||
|
<Button variant="outline" className="mt-4 gap-2">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
Tạo báo cáo mới
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Report content */}
|
||||||
|
{report.status === 'READY' && report.content && (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{Object.entries(sections).map(([key, section]) => (
|
||||||
|
<ReportSection key={key} sectionKey={key} section={section} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Section renderer ──────────────────────────────────
|
||||||
|
|
||||||
|
function ReportSection({
|
||||||
|
sectionKey,
|
||||||
|
section,
|
||||||
|
}: {
|
||||||
|
sectionKey: string;
|
||||||
|
section: SectionData;
|
||||||
|
}) {
|
||||||
|
const title = section.title || sectionKey;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border bg-card p-6">
|
||||||
|
<h2 className="mb-4 text-xl font-bold">{title}</h2>
|
||||||
|
|
||||||
|
{/* Narrative text */}
|
||||||
|
{section.content && (
|
||||||
|
<div className="prose prose-sm max-w-none text-foreground">
|
||||||
|
{section.content.split('\n').map((paragraph, i) => (
|
||||||
|
<p key={i} className="mb-2 last:mb-0">
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Charts */}
|
||||||
|
{section.charts && <ReportChartsGrid charts={section.charts} />}
|
||||||
|
|
||||||
|
{/* Data tables */}
|
||||||
|
{section.data && <DataTablesSection data={section.data} />}
|
||||||
|
|
||||||
|
{/* Infrastructure projects */}
|
||||||
|
{section.projects && section.projects.length > 0 && (
|
||||||
|
<ProjectsTable projects={section.projects} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
{section.summary && <SummaryBlock summary={section.summary} />}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Data tables ───────────────────────────────────────
|
||||||
|
|
||||||
|
function DataTablesSection({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: Record<string, Array<{ period: string; value: number; unit: string }>>;
|
||||||
|
}) {
|
||||||
|
const entries = Object.entries(data).filter(
|
||||||
|
([, arr]) => Array.isArray(arr) && arr.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
{entries.map(([key, items]) => {
|
||||||
|
const label = key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
return (
|
||||||
|
<div key={key} className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="px-3 py-2 text-left font-medium" colSpan={3}>
|
||||||
|
{label}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="px-3 py-1.5 text-left text-xs font-medium text-muted-foreground">
|
||||||
|
Kỳ
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-1.5 text-left text-xs font-medium text-muted-foreground">
|
||||||
|
Giá trị
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-1.5 text-left text-xs font-medium text-muted-foreground">
|
||||||
|
Đơn vị
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<tr key={i} className="border-b last:border-0 even:bg-muted/30">
|
||||||
|
<td className="px-3 py-1.5">{item.period}</td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
{typeof item.value === 'number'
|
||||||
|
? item.value.toLocaleString('vi-VN')
|
||||||
|
: String(item.value)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-muted-foreground">
|
||||||
|
{item.unit}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Projects table ────────────────────────────────────
|
||||||
|
|
||||||
|
function ProjectsTable({
|
||||||
|
projects,
|
||||||
|
}: {
|
||||||
|
projects: Array<Record<string, unknown>>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mt-4 overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Dự án</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Danh mục</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Trạng thái</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium">Vốn đầu tư (VND)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{projects.map((p, i) => (
|
||||||
|
<tr key={i} className="border-b last:border-0 even:bg-muted/30">
|
||||||
|
<td className="px-3 py-1.5 font-medium">
|
||||||
|
{String(p['name'] ?? '')}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5">{String(p['category'] ?? '')}</td>
|
||||||
|
<td className="px-3 py-1.5">{String(p['status'] ?? '')}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right">
|
||||||
|
{p['investmentVND']
|
||||||
|
? Number(p['investmentVND']).toLocaleString('vi-VN')
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Summary block ─────────────────────────────────────
|
||||||
|
|
||||||
|
function SummaryBlock({ summary }: { summary: Record<string, unknown> }) {
|
||||||
|
return (
|
||||||
|
<div className="mt-4 rounded-lg bg-muted/50 p-4">
|
||||||
|
{Object.entries(summary).map(([key, val]) => {
|
||||||
|
const label = key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
|
||||||
|
if (typeof val === 'number') {
|
||||||
|
return (
|
||||||
|
<p key={key} className="text-sm">
|
||||||
|
<span className="font-medium">{label}:</span>{' '}
|
||||||
|
{val.toLocaleString('vi-VN')}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof val === 'object' && val !== null) {
|
||||||
|
return (
|
||||||
|
<div key={key} className="mt-2">
|
||||||
|
<p className="text-sm font-medium">{label}:</p>
|
||||||
|
<ul className="ml-4 mt-1 list-disc text-sm text-muted-foreground">
|
||||||
|
{Object.entries(val as Record<string, unknown>).map(([k, v]) => (
|
||||||
|
<li key={k}>
|
||||||
|
{k}: {String(v)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
apps/web/app/[locale]/(public)/bao-cao/page.tsx
Normal file
173
apps/web/app/[locale]/(public)/bao-cao/page.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FileText, Plus, X } from 'lucide-react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { ReportCard } from '@/components/reports/report-card';
|
||||||
|
import { REPORT_TYPES } from '@/components/reports/report-type-badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Link } from '@/i18n/navigation';
|
||||||
|
import { useReportsList, useDeleteReport } from '@/lib/hooks/use-reports';
|
||||||
|
import type { ReportType } from '@/lib/reports-api';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 12;
|
||||||
|
|
||||||
|
export default function BaoCaoPage() {
|
||||||
|
const [typeFilter, setTypeFilter] = React.useState<ReportType | undefined>();
|
||||||
|
const [page, setPage] = React.useState(1);
|
||||||
|
|
||||||
|
const offset = (page - 1) * PAGE_SIZE;
|
||||||
|
const { data, isLoading, isError } = useReportsList({
|
||||||
|
type: typeFilter,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteReport = useDeleteReport();
|
||||||
|
|
||||||
|
const totalPages = data ? Math.ceil(data.total / PAGE_SIZE) : 0;
|
||||||
|
|
||||||
|
const handleTypeChange = (type: ReportType | undefined) => {
|
||||||
|
setTypeFilter(type);
|
||||||
|
setPage(1);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
setPage(newPage);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
deleteReport.mutate(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-6">
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold md:text-3xl">Báo cáo</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
Quản lý và tạo báo cáo phân tích bất động sản
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/bao-cao/tao-moi">
|
||||||
|
<Button className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Tạo báo cáo mới
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type filter tabs */}
|
||||||
|
<div className="flex gap-1 overflow-x-auto border-b" role="tablist">
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={!typeFilter}
|
||||||
|
className={`shrink-0 border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
|
!typeFilter
|
||||||
|
? 'border-primary text-primary'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleTypeChange(undefined)}
|
||||||
|
>
|
||||||
|
Tất cả
|
||||||
|
</button>
|
||||||
|
{REPORT_TYPES.map(({ value, label }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={typeFilter === value}
|
||||||
|
className={`shrink-0 border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
|
typeFilter === value
|
||||||
|
? 'border-primary text-primary'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleTypeChange(value)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{typeFilter && (
|
||||||
|
<button
|
||||||
|
className="ml-auto shrink-0 px-2 py-2 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => handleTypeChange(undefined)}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
<div className="mt-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-32 animate-pulse rounded-lg bg-muted" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Không thể tải danh sách báo cáo. Vui lòng thử lại.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" className="mt-4" onClick={() => setPage(page)}>
|
||||||
|
Thử lại
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : data && data.data.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
|
{data.total} báo cáo
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{data.data.map((report) => (
|
||||||
|
<ReportCard key={report.id} report={report} onDelete={handleDelete} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-8 flex items-center justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page === 1}
|
||||||
|
onClick={() => handlePageChange(page - 1)}
|
||||||
|
>
|
||||||
|
Trước
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Trang {page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => handlePageChange(page + 1)}
|
||||||
|
>
|
||||||
|
Sau
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<FileText className="mx-auto h-12 w-12 text-muted-foreground/30" />
|
||||||
|
<p className="mt-4 text-lg font-medium">Chưa có báo cáo nào</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Tạo báo cáo phân tích đầu tiên của bạn
|
||||||
|
</p>
|
||||||
|
<Link href="/bao-cao/tao-moi">
|
||||||
|
<Button className="mt-4 gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Tạo báo cáo mới
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
410
apps/web/app/[locale]/(public)/bao-cao/tao-moi/page.tsx
Normal file
410
apps/web/app/[locale]/(public)/bao-cao/tao-moi/page.tsx
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ArrowLeft, ArrowRight, CheckCircle, FileText, Loader2 } from 'lucide-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { REPORT_TYPES } from '@/components/reports/report-type-badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useGenerateReport } from '@/lib/hooks/use-reports';
|
||||||
|
import type { ReportType } from '@/lib/reports-api';
|
||||||
|
|
||||||
|
// ─── Constants ─────────────────────────────────────────
|
||||||
|
|
||||||
|
const PROVINCES = [
|
||||||
|
'Hồ Chí Minh', 'Hà Nội', 'Đà Nẵng', 'Bình Dương', 'Đồng Nai',
|
||||||
|
'Long An', 'Bà Rịa - Vũng Tàu', 'Bắc Ninh', 'Hải Phòng', 'Hải Dương',
|
||||||
|
'Hưng Yên', 'Quảng Ninh', 'Thái Nguyên', 'Vĩnh Phúc', 'Cần Thơ',
|
||||||
|
];
|
||||||
|
|
||||||
|
const HCM_DISTRICTS = [
|
||||||
|
'Quận 1', 'Quận 3', 'Quận 4', 'Quận 5', 'Quận 6', 'Quận 7',
|
||||||
|
'Quận 8', 'Quận 10', 'Quận 11', 'Quận 12', 'Bình Thạnh',
|
||||||
|
'Gò Vấp', 'Phú Nhuận', 'Tân Bình', 'Tân Phú', 'Thủ Đức',
|
||||||
|
'Bình Tân', 'Nhà Bè', 'Hóc Môn', 'Củ Chi', 'Cần Giờ',
|
||||||
|
];
|
||||||
|
|
||||||
|
const PROPERTY_TYPES = [
|
||||||
|
{ value: 'APARTMENT', label: 'Căn hộ' },
|
||||||
|
{ value: 'HOUSE', label: 'Nhà phố' },
|
||||||
|
{ value: 'VILLA', label: 'Biệt thự' },
|
||||||
|
{ value: 'LAND', label: 'Đất nền' },
|
||||||
|
{ value: 'COMMERCIAL', label: 'Thương mại' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Wizard report types — subset users can create
|
||||||
|
const WIZARD_REPORT_TYPES: ReportType[] = [
|
||||||
|
'INDUSTRIAL_LOCATION',
|
||||||
|
'RESIDENTIAL_MARKET',
|
||||||
|
'DISTRICT_ANALYSIS',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Steps ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Step = 'select_type' | 'configure' | 'review';
|
||||||
|
const STEPS: { key: Step; label: string }[] = [
|
||||||
|
{ key: 'select_type', label: 'Chọn loại' },
|
||||||
|
{ key: 'configure', label: 'Cấu hình' },
|
||||||
|
{ key: 'review', label: 'Xác nhận' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Component ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function TaoMoiPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const generateReport = useGenerateReport();
|
||||||
|
|
||||||
|
const [step, setStep] = React.useState<Step>('select_type');
|
||||||
|
const [selectedType, setSelectedType] = React.useState<ReportType | null>(null);
|
||||||
|
const [title, setTitle] = React.useState('');
|
||||||
|
|
||||||
|
// Params per type
|
||||||
|
const [province, setProvince] = React.useState('');
|
||||||
|
const [district, setDistrict] = React.useState('');
|
||||||
|
const [propertyType, setPropertyType] = React.useState('');
|
||||||
|
const [dateFrom, setDateFrom] = React.useState('');
|
||||||
|
const [dateTo, setDateTo] = React.useState('');
|
||||||
|
|
||||||
|
const stepIndex = STEPS.findIndex((s) => s.key === step);
|
||||||
|
|
||||||
|
const canProceed = () => {
|
||||||
|
switch (step) {
|
||||||
|
case 'select_type':
|
||||||
|
return !!selectedType;
|
||||||
|
case 'configure':
|
||||||
|
if (!title.trim()) return false;
|
||||||
|
if (selectedType === 'INDUSTRIAL_LOCATION') return !!province;
|
||||||
|
if (selectedType === 'RESIDENTIAL_MARKET') return !!district;
|
||||||
|
if (selectedType === 'DISTRICT_ANALYSIS') return !!district;
|
||||||
|
return true;
|
||||||
|
case 'review':
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildParams = (): Record<string, unknown> => {
|
||||||
|
switch (selectedType) {
|
||||||
|
case 'INDUSTRIAL_LOCATION':
|
||||||
|
return { province };
|
||||||
|
case 'RESIDENTIAL_MARKET':
|
||||||
|
return {
|
||||||
|
district,
|
||||||
|
propertyType: propertyType || undefined,
|
||||||
|
dateFrom: dateFrom || undefined,
|
||||||
|
dateTo: dateTo || undefined,
|
||||||
|
};
|
||||||
|
case 'DISTRICT_ANALYSIS':
|
||||||
|
return { district };
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (step === 'select_type') setStep('configure');
|
||||||
|
else if (step === 'configure') setStep('review');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (step === 'configure') setStep('select_type');
|
||||||
|
else if (step === 'review') setStep('configure');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!selectedType) return;
|
||||||
|
try {
|
||||||
|
const result = await generateReport.mutateAsync({
|
||||||
|
type: selectedType,
|
||||||
|
title: title.trim(),
|
||||||
|
params: buildParams(),
|
||||||
|
});
|
||||||
|
router.push(`/bao-cao/${result.reportId}`);
|
||||||
|
} catch {
|
||||||
|
// Error handled by mutation state
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedTypeInfo = REPORT_TYPES.find((t) => t.value === selectedType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold">Tạo báo cáo mới</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
Chọn loại báo cáo và cấu hình thông số phân tích
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step indicator */}
|
||||||
|
<div className="mb-8 flex items-center justify-between">
|
||||||
|
{STEPS.map((s, i) => (
|
||||||
|
<React.Fragment key={s.key}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${
|
||||||
|
i < stepIndex
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: i === stepIndex
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{i < stepIndex ? <CheckCircle className="h-4 w-4" /> : i + 1}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`hidden text-sm font-medium sm:inline ${
|
||||||
|
i <= stepIndex ? 'text-foreground' : 'text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{i < STEPS.length - 1 && (
|
||||||
|
<div
|
||||||
|
className={`mx-2 h-0.5 flex-1 ${
|
||||||
|
i < stepIndex ? 'bg-primary' : 'bg-muted'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step content */}
|
||||||
|
<div className="rounded-lg border bg-card p-6">
|
||||||
|
{/* Step 1: Select type */}
|
||||||
|
{step === 'select_type' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">Chọn loại báo cáo</h2>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
{WIZARD_REPORT_TYPES.map((typeValue) => {
|
||||||
|
const info = REPORT_TYPES.find((t) => t.value === typeValue);
|
||||||
|
if (!info) return null;
|
||||||
|
const Icon = info.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={typeValue}
|
||||||
|
className={`flex flex-col items-center gap-2 rounded-lg border-2 p-4 text-center transition-colors ${
|
||||||
|
selectedType === typeValue
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-muted hover:border-muted-foreground/30'
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedType(typeValue)}
|
||||||
|
>
|
||||||
|
<Icon className="h-8 w-8 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">{info.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Configure */}
|
||||||
|
{step === 'configure' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">Cấu hình báo cáo</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="title">Tiêu đề báo cáo</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Nhập tiêu đề báo cáo..."
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedType === 'INDUSTRIAL_LOCATION' && (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="province">Tỉnh/Thành phố</Label>
|
||||||
|
<select
|
||||||
|
id="province"
|
||||||
|
value={province}
|
||||||
|
onChange={(e) => setProvince(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Chọn tỉnh/thành phố</option>
|
||||||
|
{PROVINCES.map((p) => (
|
||||||
|
<option key={p} value={p}>{p}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedType === 'RESIDENTIAL_MARKET' && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="district">Quận/Huyện</Label>
|
||||||
|
<select
|
||||||
|
id="district"
|
||||||
|
value={district}
|
||||||
|
onChange={(e) => setDistrict(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Chọn quận/huyện</option>
|
||||||
|
{HCM_DISTRICTS.map((d) => (
|
||||||
|
<option key={d} value={d}>{d}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="propertyType">Loại bất động sản</Label>
|
||||||
|
<select
|
||||||
|
id="propertyType"
|
||||||
|
value={propertyType}
|
||||||
|
onChange={(e) => setPropertyType(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Tất cả loại</option>
|
||||||
|
{PROPERTY_TYPES.map((pt) => (
|
||||||
|
<option key={pt.value} value={pt.value}>{pt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="dateFrom">Từ ngày</Label>
|
||||||
|
<Input
|
||||||
|
id="dateFrom"
|
||||||
|
type="date"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={(e) => setDateFrom(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="dateTo">Đến ngày</Label>
|
||||||
|
<Input
|
||||||
|
id="dateTo"
|
||||||
|
type="date"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={(e) => setDateTo(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedType === 'DISTRICT_ANALYSIS' && (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="district-analysis">Quận/Huyện</Label>
|
||||||
|
<select
|
||||||
|
id="district-analysis"
|
||||||
|
value={district}
|
||||||
|
onChange={(e) => setDistrict(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Chọn quận/huyện</option>
|
||||||
|
{HCM_DISTRICTS.map((d) => (
|
||||||
|
<option key={d} value={d}>{d}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Review */}
|
||||||
|
{step === 'review' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">Xác nhận báo cáo</h2>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-muted/50 p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Loại báo cáo</span>
|
||||||
|
<span className="text-sm font-medium">{selectedTypeInfo?.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Tiêu đề</span>
|
||||||
|
<span className="text-sm font-medium">{title}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{province && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Tỉnh/Thành phố</span>
|
||||||
|
<span className="text-sm font-medium">{province}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{district && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Quận/Huyện</span>
|
||||||
|
<span className="text-sm font-medium">{district}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{propertyType && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Loại BĐS</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{PROPERTY_TYPES.find((pt) => pt.value === propertyType)?.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{dateFrom && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Từ ngày</span>
|
||||||
|
<span className="text-sm font-medium">{dateFrom}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{dateTo && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Đến ngày</span>
|
||||||
|
<span className="text-sm font-medium">{dateTo}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{generateReport.isError && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
Không thể tạo báo cáo. Vui lòng thử lại.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="mt-6 flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleBack}
|
||||||
|
disabled={step === 'select_type'}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Quay lại
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{step === 'review' ? (
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={generateReport.isPending}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{generateReport.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{generateReport.isPending ? 'Đang tạo...' : 'Tạo báo cáo'}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={!canProceed()}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
Tiếp tục
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { TransferWizardClient } from '@/components/chuyen-nhuong/transfer-wizard-client';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Đăng tin chuyển nhượng',
|
||||||
|
description: 'Đăng tin chuyển nhượng nội thất, thiết bị hoặc mặt bằng',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DangTinPage() {
|
||||||
|
return <TransferWizardClient />;
|
||||||
|
}
|
||||||
@@ -2,12 +2,15 @@ import type { Metadata } from 'next';
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { DuAnDetailClient } from '@/components/du-an/du-an-detail-client';
|
import { DuAnDetailClient } from '@/components/du-an/du-an-detail-client';
|
||||||
import { fetchProjectBySlug } from '@/lib/du-an-server';
|
import { fetchProjectBySlug } from '@/lib/du-an-server';
|
||||||
|
import { isResidentialProjectsEnabledServer } from '@/lib/hooks/use-residential-projects-flag';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ slug: string; locale: string }>;
|
params: Promise<{ slug: string; locale: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||||
|
if (!isResidentialProjectsEnabledServer()) return { title: 'Không tìm thấy dự án' };
|
||||||
|
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const project = await fetchProjectBySlug(slug);
|
const project = await fetchProjectBySlug(slug);
|
||||||
if (!project) return { title: 'Không tìm thấy dự án' };
|
if (!project) return { title: 'Không tìm thấy dự án' };
|
||||||
@@ -27,6 +30,10 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function DuAnDetailPage({ params }: PageProps) {
|
export default async function DuAnDetailPage({ params }: PageProps) {
|
||||||
|
if (!isResidentialProjectsEnabledServer()) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const project = await fetchProjectBySlug(slug);
|
const project = await fetchProjectBySlug(slug);
|
||||||
|
|
||||||
|
|||||||
180
apps/web/app/[locale]/(public)/du-an/__tests__/du-an.spec.tsx
Normal file
180
apps/web/app/[locale]/(public)/du-an/__tests__/du-an.spec.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/* eslint-disable import-x/order */
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock next-intl
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useTranslations: () => (key: string) => key,
|
||||||
|
useLocale: () => 'vi',
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/i18n/navigation', () => ({
|
||||||
|
Link: ({
|
||||||
|
children,
|
||||||
|
href,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
href: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}) => (
|
||||||
|
<a href={href} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/image', () => ({
|
||||||
|
default: ({ src, alt, ...props }: { src: string; alt: string; [key: string]: unknown }) => (
|
||||||
|
<img src={src} alt={alt} {...(props as React.ImgHTMLAttributes<HTMLImageElement>)} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/dynamic', () => ({
|
||||||
|
default: () => {
|
||||||
|
const Stub = () => <div data-testid="dynamic-stub" />;
|
||||||
|
Stub.displayName = 'DynamicStub';
|
||||||
|
return Stub;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { notFoundMock } = vi.hoisted(() => ({
|
||||||
|
notFoundMock: vi.fn(() => {
|
||||||
|
throw new Error('NEXT_NOT_FOUND');
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
notFound: notFoundMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/hooks/use-residential-projects-flag', () => ({
|
||||||
|
useResidentialProjectsFlag: vi.fn(() => true),
|
||||||
|
isResidentialProjectsEnabledServer: vi.fn(() => true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock TanStack Query
|
||||||
|
const mockSearchData = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'proj-1',
|
||||||
|
slug: 'vinhomes-grand-park',
|
||||||
|
name: 'Vinhomes Grand Park',
|
||||||
|
status: 'SELLING' as const,
|
||||||
|
developer: { id: 'dev-1', name: 'Vingroup', logoUrl: null, totalProjects: 10 },
|
||||||
|
city: 'Hồ Chí Minh',
|
||||||
|
district: 'Quận 9',
|
||||||
|
address: '1 Nguyễn Xiển',
|
||||||
|
latitude: 10.84,
|
||||||
|
longitude: 106.84,
|
||||||
|
thumbnailUrl: '/img/project1.jpg',
|
||||||
|
totalArea: 271000,
|
||||||
|
totalUnits: 10000,
|
||||||
|
propertyTypes: ['APARTMENT' as const, 'VILLA' as const],
|
||||||
|
minPrice: '2000000000',
|
||||||
|
maxPrice: '5000000000',
|
||||||
|
completionDate: '2024-12-01',
|
||||||
|
createdAt: '2023-01-15',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
limit: 12,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('@/lib/hooks/use-du-an', () => ({
|
||||||
|
useProjectsSearch: vi.fn(() => ({
|
||||||
|
data: mockSearchData,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
})),
|
||||||
|
useProjectDetail: vi.fn(() => ({
|
||||||
|
data: null,
|
||||||
|
isLoading: false,
|
||||||
|
})),
|
||||||
|
useProjectLinkedListings: vi.fn(() => ({
|
||||||
|
data: null,
|
||||||
|
isLoading: false,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@tanstack/react-query', () => ({
|
||||||
|
useQuery: vi.fn(),
|
||||||
|
QueryClient: vi.fn(),
|
||||||
|
QueryClientProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import DuAnPage from '../page';
|
||||||
|
|
||||||
|
describe('DuAnPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the page header', () => {
|
||||||
|
render(<DuAnPage />);
|
||||||
|
expect(screen.getByText('Dự án bất động sản')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders project cards from search data', () => {
|
||||||
|
render(<DuAnPage />);
|
||||||
|
expect(screen.getByText('Vinhomes Grand Park')).toBeDefined();
|
||||||
|
expect(screen.getByText('Quận 9, Hồ Chí Minh')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders view mode toggle buttons', () => {
|
||||||
|
render(<DuAnPage />);
|
||||||
|
expect(screen.getByLabelText('Xem dạng lưới')).toBeDefined();
|
||||||
|
expect(screen.getByLabelText('Xem dạng danh sách')).toBeDefined();
|
||||||
|
expect(screen.getByLabelText('Xem trên bản đồ')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading skeleton when isLoading', async () => {
|
||||||
|
const { useProjectsSearch } = await import('@/lib/hooks/use-du-an');
|
||||||
|
vi.mocked(useProjectsSearch).mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: true,
|
||||||
|
isError: false,
|
||||||
|
} as ReturnType<typeof useProjectsSearch>);
|
||||||
|
|
||||||
|
const { container } = render(<DuAnPage />);
|
||||||
|
const skeletons = container.querySelectorAll('.animate-pulse');
|
||||||
|
expect(skeletons.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when no results', async () => {
|
||||||
|
const { useProjectsSearch } = await import('@/lib/hooks/use-du-an');
|
||||||
|
vi.mocked(useProjectsSearch).mockReturnValue({
|
||||||
|
data: { data: [], total: 0, page: 1, limit: 12, totalPages: 0 },
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
} as unknown as ReturnType<typeof useProjectsSearch>);
|
||||||
|
|
||||||
|
render(<DuAnPage />);
|
||||||
|
expect(screen.getByText('Không tìm thấy dự án')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows total results count', async () => {
|
||||||
|
const { useProjectsSearch } = await import('@/lib/hooks/use-du-an');
|
||||||
|
vi.mocked(useProjectsSearch).mockReturnValue({
|
||||||
|
data: mockSearchData,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
} as ReturnType<typeof useProjectsSearch>);
|
||||||
|
|
||||||
|
render(<DuAnPage />);
|
||||||
|
expect(screen.getByText('1 dự án được tìm thấy')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls notFound when residential_projects flag is disabled', async () => {
|
||||||
|
const { useResidentialProjectsFlag } = await import(
|
||||||
|
'@/lib/hooks/use-residential-projects-flag'
|
||||||
|
);
|
||||||
|
vi.mocked(useResidentialProjectsFlag).mockReturnValue(false);
|
||||||
|
|
||||||
|
expect(() => render(<DuAnPage />)).toThrow('NEXT_NOT_FOUND');
|
||||||
|
expect(notFoundMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { Building2, LayoutGrid, List, Map, MapPin } from 'lucide-react';
|
import { Building2, LayoutGrid, List, Map, MapPin } from 'lucide-react';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ProjectCard } from '@/components/du-an/project-card';
|
import { ProjectCard } from '@/components/du-an/project-card';
|
||||||
import { ProjectFilterBar } from '@/components/du-an/project-filter-bar';
|
import { ProjectFilterBar } from '@/components/du-an/project-filter-bar';
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
type SearchProjectsParams,
|
type SearchProjectsParams,
|
||||||
} from '@/lib/du-an-api';
|
} from '@/lib/du-an-api';
|
||||||
import { useProjectsSearch } from '@/lib/hooks/use-du-an';
|
import { useProjectsSearch } from '@/lib/hooks/use-du-an';
|
||||||
|
import { useResidentialProjectsFlag } from '@/lib/hooks/use-residential-projects-flag';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const ProjectMap = dynamic(
|
const ProjectMap = dynamic(
|
||||||
@@ -31,6 +33,7 @@ const PAGE_SIZE = 12;
|
|||||||
type ViewMode = 'grid' | 'list' | 'map';
|
type ViewMode = 'grid' | 'list' | 'map';
|
||||||
|
|
||||||
export default function DuAnPage() {
|
export default function DuAnPage() {
|
||||||
|
const flagEnabled = useResidentialProjectsFlag();
|
||||||
const [filters, setFilters] = React.useState<SearchProjectsParams>({
|
const [filters, setFilters] = React.useState<SearchProjectsParams>({
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
@@ -39,6 +42,10 @@ export default function DuAnPage() {
|
|||||||
|
|
||||||
const { data, isLoading, isError } = useProjectsSearch(filters);
|
const { data, isLoading, isError } = useProjectsSearch(filters);
|
||||||
|
|
||||||
|
if (!flagEnabled) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
const handleFilterChange = (newFilters: SearchProjectsParams) => {
|
const handleFilterChange = (newFilters: SearchProjectsParams) => {
|
||||||
setFilters({ ...newFilters, limit: PAGE_SIZE });
|
setFilters({ ...newFilters, limit: PAGE_SIZE });
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/* eslint-disable import-x/order */
|
||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
import type { ListingDetail } from '@/lib/listings-api';
|
||||||
|
|
||||||
|
// Mock the server-side listing fetch
|
||||||
|
vi.mock('@/lib/listings-server', () => ({
|
||||||
|
fetchListingById: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Avoid pulling in the heavy client component during unit tests
|
||||||
|
vi.mock('@/components/listings/listing-detail-client', () => ({
|
||||||
|
ListingDetailClient: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/seo/json-ld', () => ({
|
||||||
|
JsonLd: () => null,
|
||||||
|
generateBreadcrumbJsonLd: () => ({}),
|
||||||
|
generateListingJsonLd: () => ({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { fetchListingById } from '@/lib/listings-server';
|
||||||
|
import { generateMetadata } from '../page';
|
||||||
|
|
||||||
|
const mockedFetch = vi.mocked(fetchListingById);
|
||||||
|
|
||||||
|
function buildListing(overrides: Partial<ListingDetail> = {}): ListingDetail {
|
||||||
|
return {
|
||||||
|
id: 'listing-1',
|
||||||
|
status: 'APPROVED',
|
||||||
|
transactionType: 'SALE',
|
||||||
|
priceVND: '3500000000',
|
||||||
|
pricePerM2: null,
|
||||||
|
rentPriceMonthly: null,
|
||||||
|
commissionPct: null,
|
||||||
|
viewCount: 0,
|
||||||
|
saveCount: 0,
|
||||||
|
inquiryCount: 0,
|
||||||
|
publishedAt: null,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
property: {
|
||||||
|
id: 'prop-1',
|
||||||
|
propertyType: 'APARTMENT',
|
||||||
|
title: 'Căn hộ cao cấp Quận 1',
|
||||||
|
description: 'Đẹp, thoáng',
|
||||||
|
address: '123 Lê Lợi',
|
||||||
|
ward: 'Bến Nghé',
|
||||||
|
district: 'Quận 1',
|
||||||
|
city: 'Hồ Chí Minh',
|
||||||
|
areaM2: 75,
|
||||||
|
bedrooms: 2,
|
||||||
|
bathrooms: 2,
|
||||||
|
floors: 1,
|
||||||
|
direction: null,
|
||||||
|
yearBuilt: null,
|
||||||
|
legalStatus: null,
|
||||||
|
amenities: null,
|
||||||
|
projectName: null,
|
||||||
|
latitude: null,
|
||||||
|
longitude: null,
|
||||||
|
media: [
|
||||||
|
{
|
||||||
|
id: 'img-1',
|
||||||
|
url: 'https://cdn.example.com/img1.jpg',
|
||||||
|
type: 'image',
|
||||||
|
order: 0,
|
||||||
|
caption: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
seller: { id: 'u-1', fullName: 'Nguyen Van A', phone: '0900000000' },
|
||||||
|
agent: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('listing page generateMetadata', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a not-found title when the listing is missing', async () => {
|
||||||
|
mockedFetch.mockResolvedValueOnce(null);
|
||||||
|
const meta = await generateMetadata({
|
||||||
|
params: Promise.resolve({ locale: 'vi', id: 'missing' }),
|
||||||
|
});
|
||||||
|
expect(meta.title).toMatch(/Không tìm thấy/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds OG + Twitter tags with image, canonical and alternates', async () => {
|
||||||
|
mockedFetch.mockResolvedValueOnce(buildListing());
|
||||||
|
const meta = await generateMetadata({
|
||||||
|
params: Promise.resolve({ locale: 'vi', id: 'listing-1' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(meta.title).toContain('Căn hộ cao cấp Quận 1');
|
||||||
|
expect(String(meta.description)).toContain('75 m');
|
||||||
|
expect(String(meta.description)).toContain('2 PN');
|
||||||
|
expect(String(meta.description)).toContain('Quận 1');
|
||||||
|
|
||||||
|
expect(meta.alternates?.canonical).toMatch(/\/vi\/listings\/listing-1$/);
|
||||||
|
expect(meta.alternates?.languages?.vi).toMatch(/\/vi\/listings\/listing-1$/);
|
||||||
|
expect(meta.alternates?.languages?.en).toMatch(/\/en\/listings\/listing-1$/);
|
||||||
|
|
||||||
|
const og = meta.openGraph as Record<string, unknown>;
|
||||||
|
expect(og.type).toBe('article');
|
||||||
|
expect(og.locale).toBe('vi_VN');
|
||||||
|
expect(og.siteName).toBe('GoodGo');
|
||||||
|
const ogImages = og.images as Array<{ url: string; width: number; height: number }>;
|
||||||
|
expect(ogImages[0]?.url).toBe('https://cdn.example.com/img1.jpg');
|
||||||
|
expect(ogImages[0]?.width).toBe(1200);
|
||||||
|
expect(ogImages[0]?.height).toBe(630);
|
||||||
|
|
||||||
|
const twitter = meta.twitter as Record<string, unknown>;
|
||||||
|
expect(twitter.card).toBe('summary_large_image');
|
||||||
|
expect((twitter.images as string[])[0]).toBe('https://cdn.example.com/img1.jpg');
|
||||||
|
|
||||||
|
expect(meta.other?.['og:price:currency']).toBe('VND');
|
||||||
|
expect(meta.other?.['og:price:amount']).toBe('3500000000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to default OG image when no media is present', async () => {
|
||||||
|
mockedFetch.mockResolvedValueOnce(
|
||||||
|
buildListing({
|
||||||
|
property: { ...buildListing().property, media: [] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const meta = await generateMetadata({
|
||||||
|
params: Promise.resolve({ locale: 'en', id: 'listing-1' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const og = meta.openGraph as Record<string, unknown>;
|
||||||
|
expect(og.locale).toBe('en_US');
|
||||||
|
const ogImages = og.images as Array<{ url: string }>;
|
||||||
|
expect(ogImages[0]?.url).toBe('/og-image.png');
|
||||||
|
});
|
||||||
|
});
|
||||||
21
apps/web/components/listings/__tests__/social-share.spec.tsx
Normal file
21
apps/web/components/listings/__tests__/social-share.spec.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { SocialShare } from '../social-share';
|
||||||
|
|
||||||
|
describe('SocialShare', () => {
|
||||||
|
it('renders Facebook, Zalo and copy-link actions', () => {
|
||||||
|
render(<SocialShare listingId="abc-123" listingTitle="Căn hộ mẫu" />);
|
||||||
|
expect(screen.getByLabelText('Chia sẻ lên Facebook')).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText('Chia sẻ lên Zalo')).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText('Sao chép liên kết')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the backend QR code image when toggled on', () => {
|
||||||
|
render(<SocialShare listingId="abc-123" listingTitle="Căn hộ mẫu" />);
|
||||||
|
const toggle = screen.getByLabelText('Hiện mã QR');
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
const img = screen.getByAltText('Mã QR cho Căn hộ mẫu') as HTMLImageElement;
|
||||||
|
expect(img).toBeInTheDocument();
|
||||||
|
expect(img.src).toContain('/listings/abc-123/qr-code');
|
||||||
|
});
|
||||||
|
});
|
||||||
149
apps/web/components/valuation/__tests__/comparables-map.spec.tsx
Normal file
149
apps/web/components/valuation/__tests__/comparables-map.spec.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import type { ValuationComparable } from '@/lib/valuation-api';
|
||||||
|
import { ComparablesMap } from '../comparables-map';
|
||||||
|
|
||||||
|
// Mapbox GL does not run cleanly in jsdom — mock with a minimal stand-in
|
||||||
|
// that records addTo calls so we can assert marker count.
|
||||||
|
const markerAddTo = vi.fn();
|
||||||
|
const mapAddControl = vi.fn();
|
||||||
|
const mapFitBounds = vi.fn();
|
||||||
|
const mapFlyTo = vi.fn();
|
||||||
|
const mapRemove = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('mapbox-gl', () => {
|
||||||
|
class MockMap {
|
||||||
|
addControl = mapAddControl;
|
||||||
|
fitBounds = mapFitBounds;
|
||||||
|
flyTo = mapFlyTo;
|
||||||
|
remove = mapRemove;
|
||||||
|
}
|
||||||
|
class MockNavigationControl {}
|
||||||
|
class MockAttributionControl {}
|
||||||
|
|
||||||
|
class MockMarker {
|
||||||
|
setLngLat() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
setPopup() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
addTo() {
|
||||||
|
markerAddTo();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
remove() {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockPopup {
|
||||||
|
setHTML() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockLngLatBounds {
|
||||||
|
extend() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
isEmpty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
accessToken: '',
|
||||||
|
Map: MockMap,
|
||||||
|
NavigationControl: MockNavigationControl,
|
||||||
|
AttributionControl: MockAttributionControl,
|
||||||
|
Marker: MockMarker,
|
||||||
|
Popup: MockPopup,
|
||||||
|
LngLatBounds: MockLngLatBounds,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
|
||||||
|
|
||||||
|
const sampleComparables: ValuationComparable[] = [
|
||||||
|
{
|
||||||
|
id: 'comp-1',
|
||||||
|
title: 'Căn hộ tương tự A',
|
||||||
|
address: '456 Nguyễn Hữu Thọ',
|
||||||
|
district: 'Quận 7',
|
||||||
|
priceVND: '4800000000',
|
||||||
|
areaM2: 78,
|
||||||
|
pricePerM2: 61_500_000,
|
||||||
|
similarity: 0.92,
|
||||||
|
latitude: 10.73,
|
||||||
|
longitude: 106.72,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'comp-2',
|
||||||
|
title: 'Căn hộ tương tự B',
|
||||||
|
address: '789 Phạm Viết Chánh',
|
||||||
|
district: 'Bình Thạnh',
|
||||||
|
priceVND: '5200000000',
|
||||||
|
areaM2: 82,
|
||||||
|
pricePerM2: 63_400_000,
|
||||||
|
similarity: 0.7,
|
||||||
|
latitude: 10.8,
|
||||||
|
longitude: 106.7,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('ComparablesMap', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
markerAddTo.mockClear();
|
||||||
|
mapAddControl.mockClear();
|
||||||
|
mapFitBounds.mockClear();
|
||||||
|
mapFlyTo.mockClear();
|
||||||
|
mapRemove.mockClear();
|
||||||
|
process.env['NEXT_PUBLIC_MAPBOX_TOKEN'] = 'pk.test';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete (process.env as Record<string, string | undefined>)[
|
||||||
|
'NEXT_PUBLIC_MAPBOX_TOKEN'
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders header and descriptor', () => {
|
||||||
|
render(<ComparablesMap comparables={sampleComparables} />);
|
||||||
|
expect(screen.getByText('Bản đồ so sánh')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/2 BĐS so sánh/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders prompt when mapbox token is missing', () => {
|
||||||
|
delete (process.env as Record<string, string | undefined>)[
|
||||||
|
'NEXT_PUBLIC_MAPBOX_TOKEN'
|
||||||
|
];
|
||||||
|
render(<ComparablesMap comparables={sampleComparables} />);
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when no comparables have coordinates', () => {
|
||||||
|
const withoutCoords = sampleComparables.map(
|
||||||
|
({ latitude: _lat, longitude: _lng, ...rest }) => rest,
|
||||||
|
);
|
||||||
|
render(<ComparablesMap comparables={withoutCoords} />);
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Không có toạ độ cho các BĐS so sánh/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a marker for each geolocated comparable plus subject pin', () => {
|
||||||
|
render(
|
||||||
|
<ComparablesMap
|
||||||
|
comparables={sampleComparables}
|
||||||
|
subjectLatitude={10.77}
|
||||||
|
subjectLongitude={106.7}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(markerAddTo).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -64,7 +64,7 @@ describe('ValuationResults', () => {
|
|||||||
|
|
||||||
it('renders price drivers section', () => {
|
it('renders price drivers section', () => {
|
||||||
render(<ValuationResults result={mockResult} />);
|
render(<ValuationResults result={mockResult} />);
|
||||||
expect(screen.getByText('Yếu tố chính')).toBeInTheDocument();
|
expect(screen.getByText('Yếu tố ảnh hưởng giá')).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Vị trí trung tâm/)).toBeInTheDocument();
|
expect(screen.getByText(/Vị trí trung tâm/)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Tầng thấp/)).toBeInTheDocument();
|
expect(screen.getByText(/Tầng thấp/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -82,7 +82,7 @@ describe('ValuationResults', () => {
|
|||||||
it('hides drivers section when empty', () => {
|
it('hides drivers section when empty', () => {
|
||||||
const noDrivers = { ...mockResult, priceDrivers: [] };
|
const noDrivers = { ...mockResult, priceDrivers: [] };
|
||||||
render(<ValuationResults result={noDrivers} />);
|
render(<ValuationResults result={noDrivers} />);
|
||||||
expect(screen.queryByText('Yếu tố chính')).not.toBeInTheDocument();
|
expect(screen.queryByText('Yếu tố ảnh hưởng giá')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { PriceDriver } from '@/lib/valuation-api';
|
||||||
|
import { ValueDriversChart } from '../value-drivers-chart';
|
||||||
|
|
||||||
|
// Recharts uses ResizeObserver and SVG path measurements that jsdom does not
|
||||||
|
// implement. Stub ResponsiveContainer so child bars render in tests.
|
||||||
|
vi.mock('recharts', async () => {
|
||||||
|
const actual = (await vi.importActual('recharts')) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div data-testid="chart-container" style={{ width: 800, height: 400 }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const drivers: PriceDriver[] = [
|
||||||
|
{ feature: 'area_m2', impact: 20, direction: 'positive' },
|
||||||
|
{ feature: 'building_age_years', impact: -8, direction: 'negative' },
|
||||||
|
{ feature: 'distance_to_cbd_km', impact: -4.5, direction: 'negative' },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('ValueDriversChart', () => {
|
||||||
|
it('renders header and description', () => {
|
||||||
|
render(<ValueDriversChart drivers={drivers} />);
|
||||||
|
expect(screen.getByText('Yếu tố ảnh hưởng giá')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Biểu đồ thác nước/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when drivers list is empty', () => {
|
||||||
|
const { container } = render(<ValueDriversChart drivers={[]} />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders chart container when drivers are provided', () => {
|
||||||
|
render(<ValueDriversChart drivers={drivers} />);
|
||||||
|
expect(screen.getByTestId('chart-container')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
230
apps/web/components/valuation/comparables-map.tsx
Normal file
230
apps/web/components/valuation/comparables-map.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/* eslint-disable import-x/no-named-as-default-member */
|
||||||
|
import mapboxgl from 'mapbox-gl';
|
||||||
|
import * as React from 'react';
|
||||||
|
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
|
||||||
|
import type { ValuationComparable } from '@/lib/valuation-api';
|
||||||
|
|
||||||
|
interface ComparablesMapProps {
|
||||||
|
comparables: ValuationComparable[];
|
||||||
|
subjectLatitude?: number;
|
||||||
|
subjectLongitude?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CENTER: [number, number] = [106.6297, 10.8231];
|
||||||
|
const DEFAULT_ZOOM = 11;
|
||||||
|
|
||||||
|
function similarityColor(sim: number): string {
|
||||||
|
if (sim >= 0.85) return '#16a34a';
|
||||||
|
if (sim >= 0.7) return '#eab308';
|
||||||
|
return '#dc2626';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComparablesMap({
|
||||||
|
comparables,
|
||||||
|
subjectLatitude,
|
||||||
|
subjectLongitude,
|
||||||
|
className,
|
||||||
|
}: ComparablesMapProps) {
|
||||||
|
const mapContainerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const mapRef = React.useRef<mapboxgl.Map | null>(null);
|
||||||
|
const markersRef = React.useRef<mapboxgl.Marker[]>([]);
|
||||||
|
|
||||||
|
const geoComparables = React.useMemo(
|
||||||
|
() =>
|
||||||
|
comparables.filter(
|
||||||
|
(c) => c.latitude != null && c.longitude != null,
|
||||||
|
),
|
||||||
|
[comparables],
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!mapContainerRef.current) return;
|
||||||
|
|
||||||
|
const token = process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
mapboxgl.accessToken = token;
|
||||||
|
|
||||||
|
const map = new mapboxgl.Map({
|
||||||
|
container: mapContainerRef.current,
|
||||||
|
style: 'mapbox://styles/mapbox/streets-v12',
|
||||||
|
center: DEFAULT_CENTER,
|
||||||
|
zoom: DEFAULT_ZOOM,
|
||||||
|
attributionControl: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addControl(new mapboxgl.NavigationControl(), 'top-right');
|
||||||
|
map.addControl(
|
||||||
|
new mapboxgl.AttributionControl({ compact: true }),
|
||||||
|
'bottom-right',
|
||||||
|
);
|
||||||
|
|
||||||
|
mapRef.current = map;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
markersRef.current.forEach((m) => m.remove());
|
||||||
|
markersRef.current = [];
|
||||||
|
|
||||||
|
const bounds = new mapboxgl.LngLatBounds();
|
||||||
|
let extended = false;
|
||||||
|
|
||||||
|
if (subjectLatitude != null && subjectLongitude != null) {
|
||||||
|
const subjectEl = document.createElement('div');
|
||||||
|
subjectEl.setAttribute('data-testid', 'comparables-map-subject');
|
||||||
|
subjectEl.style.cssText = `
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: hsl(221.2, 83.2%, 53.3%);
|
||||||
|
border: 3px solid white;
|
||||||
|
box-shadow: 0 0 0 2px hsl(221.2, 83.2%, 53.3%), 0 2px 6px rgba(0,0,0,0.3);
|
||||||
|
`;
|
||||||
|
const marker = new mapboxgl.Marker({ element: subjectEl })
|
||||||
|
.setLngLat([subjectLongitude, subjectLatitude])
|
||||||
|
.addTo(map);
|
||||||
|
markersRef.current.push(marker);
|
||||||
|
bounds.extend([subjectLongitude, subjectLatitude]);
|
||||||
|
extended = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
geoComparables.forEach((comp) => {
|
||||||
|
const color = similarityColor(comp.similarity);
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.setAttribute('data-testid', 'comparables-map-marker');
|
||||||
|
el.style.cssText = `
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
border-left: 3px solid ${color};
|
||||||
|
transition: transform 0.15s;
|
||||||
|
max-width: 180px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
`;
|
||||||
|
el.textContent = formatPrice(comp.priceVND);
|
||||||
|
el.addEventListener('mouseenter', () => {
|
||||||
|
el.style.transform = 'scale(1.08)';
|
||||||
|
});
|
||||||
|
el.addEventListener('mouseleave', () => {
|
||||||
|
el.style.transform = 'scale(1)';
|
||||||
|
});
|
||||||
|
|
||||||
|
const popup = new mapboxgl.Popup({
|
||||||
|
offset: 15,
|
||||||
|
maxWidth: '280px',
|
||||||
|
closeButton: false,
|
||||||
|
}).setHTML(
|
||||||
|
`<div style="font-family:system-ui,sans-serif;padding:4px 0;">
|
||||||
|
<p style="font-weight:600;font-size:13px;margin:0 0 4px;">${escapeHtml(comp.title)}</p>
|
||||||
|
<p style="font-size:12px;color:#666;margin:0 0 4px;">${escapeHtml(comp.address)}</p>
|
||||||
|
<p style="font-size:12px;margin:0 0 2px;">
|
||||||
|
<span style="font-weight:600;color:hsl(221.2,83.2%,53.3%);">${formatPrice(comp.priceVND)} VNĐ</span>
|
||||||
|
<span style="color:#666;margin-left:6px;">${formatPricePerM2(comp.pricePerM2)}</span>
|
||||||
|
</p>
|
||||||
|
<p style="font-size:12px;color:#666;margin:0;">
|
||||||
|
${comp.areaM2} m² · Tương đồng ${Math.round(comp.similarity * 100)}%
|
||||||
|
</p>
|
||||||
|
</div>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const marker = new mapboxgl.Marker({ element: el, anchor: 'left' })
|
||||||
|
.setLngLat([comp.longitude!, comp.latitude!])
|
||||||
|
.setPopup(popup)
|
||||||
|
.addTo(map);
|
||||||
|
|
||||||
|
markersRef.current.push(marker);
|
||||||
|
bounds.extend([comp.longitude!, comp.latitude!]);
|
||||||
|
extended = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!extended) return;
|
||||||
|
|
||||||
|
if (!bounds.isEmpty() && markersRef.current.length > 1) {
|
||||||
|
map.fitBounds(bounds, { padding: 60, maxZoom: 14 });
|
||||||
|
} else if (markersRef.current.length === 1) {
|
||||||
|
const first = geoComparables[0] ?? {
|
||||||
|
latitude: subjectLatitude,
|
||||||
|
longitude: subjectLongitude,
|
||||||
|
};
|
||||||
|
if (first.latitude != null && first.longitude != null) {
|
||||||
|
map.flyTo({ center: [first.longitude, first.latitude], zoom: 14 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [geoComparables, subjectLatitude, subjectLongitude]);
|
||||||
|
|
||||||
|
const hasToken =
|
||||||
|
typeof process !== 'undefined' &&
|
||||||
|
Boolean(process.env['NEXT_PUBLIC_MAPBOX_TOKEN']);
|
||||||
|
|
||||||
|
const hasAnyGeo =
|
||||||
|
geoComparables.length > 0 ||
|
||||||
|
(subjectLatitude != null && subjectLongitude != null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Bản đồ so sánh</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Vị trí các bất động sản tương tự được sử dụng trong mô hình AVM
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div
|
||||||
|
className={`relative overflow-hidden rounded-lg border ${className || 'h-[360px] md:h-[420px]'}`}
|
||||||
|
>
|
||||||
|
<div ref={mapContainerRef} className="h-full w-full" />
|
||||||
|
|
||||||
|
{!hasToken && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-blue-50 to-green-50 text-center text-sm text-muted-foreground">
|
||||||
|
Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN để hiển thị bản đồ
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasToken && !hasAnyGeo && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-muted/40 text-center text-sm text-muted-foreground">
|
||||||
|
Không có toạ độ cho các BĐS so sánh
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute bottom-3 left-3 rounded bg-white/90 px-2 py-1 text-xs text-muted-foreground shadow">
|
||||||
|
{geoComparables.length} BĐS so sánh
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
303
apps/web/components/valuation/valuation-compare.tsx
Normal file
303
apps/web/components/valuation/valuation-compare.tsx
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Plus, Trash2, BarChart3 } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select } from '@/components/ui/select';
|
||||||
|
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
|
||||||
|
import { useValuationBatch } from '@/lib/hooks/use-valuation';
|
||||||
|
import {
|
||||||
|
VALUATION_PROPERTY_TYPES,
|
||||||
|
CITIES,
|
||||||
|
} from '@/lib/validations/valuation';
|
||||||
|
import type { ValuationRequest, ValuationResult } from '@/lib/valuation-api';
|
||||||
|
|
||||||
|
interface PropertySlot {
|
||||||
|
id: string;
|
||||||
|
propertyType: string;
|
||||||
|
area: string;
|
||||||
|
district: string;
|
||||||
|
city: string;
|
||||||
|
bedrooms: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptySlot(index: number): PropertySlot {
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
propertyType: 'APARTMENT',
|
||||||
|
area: '',
|
||||||
|
district: '',
|
||||||
|
city: 'Ho Chi Minh',
|
||||||
|
bedrooms: '',
|
||||||
|
label: `BĐS ${index + 1}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfidenceColor(c: number): string {
|
||||||
|
if (c >= 0.8) return 'text-green-600';
|
||||||
|
if (c >= 0.5) return 'text-yellow-600';
|
||||||
|
return 'text-red-600';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfidenceVariant(c: number): 'success' | 'warning' | 'destructive' {
|
||||||
|
if (c >= 0.8) return 'success';
|
||||||
|
if (c >= 0.5) return 'warning';
|
||||||
|
return 'destructive';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ValuationCompare() {
|
||||||
|
const [slots, setSlots] = useState<PropertySlot[]>([
|
||||||
|
createEmptySlot(0),
|
||||||
|
createEmptySlot(1),
|
||||||
|
]);
|
||||||
|
const [results, setResults] = useState<ValuationResult[] | null>(null);
|
||||||
|
|
||||||
|
const batchMutation = useValuationBatch();
|
||||||
|
|
||||||
|
const updateSlot = (id: string, field: keyof PropertySlot, value: string) => {
|
||||||
|
setSlots((prev) =>
|
||||||
|
prev.map((s) => (s.id === id ? { ...s, [field]: value } : s)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSlot = () => {
|
||||||
|
if (slots.length >= 5) return;
|
||||||
|
setSlots((prev) => [...prev, createEmptySlot(prev.length)]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSlot = (id: string) => {
|
||||||
|
if (slots.length <= 2) return;
|
||||||
|
setSlots((prev) => prev.filter((s) => s.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCompare = () => {
|
||||||
|
const validSlots = slots.filter((s) => s.area && s.district);
|
||||||
|
if (validSlots.length < 2) return;
|
||||||
|
|
||||||
|
const properties: ValuationRequest[] = validSlots.map((s) => ({
|
||||||
|
propertyType: s.propertyType,
|
||||||
|
area: Number(s.area),
|
||||||
|
district: s.district,
|
||||||
|
city: s.city,
|
||||||
|
bedrooms: s.bedrooms ? Number(s.bedrooms) : undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
batchMutation.mutate(
|
||||||
|
{ properties },
|
||||||
|
{
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setResults(data.results);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const bestValue =
|
||||||
|
results && results.length > 0
|
||||||
|
? results.reduce((best, r) =>
|
||||||
|
r.pricePerM2 < best.pricePerM2 ? r : best,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-5 w-5 text-primary" />
|
||||||
|
<CardTitle>So sánh định giá</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
So sánh giá trị ước tính của nhiều bất động sản cùng lúc (2-5 BĐS)
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{slots.map((slot) => (
|
||||||
|
<div
|
||||||
|
key={slot.id}
|
||||||
|
className="rounded-lg border p-4 space-y-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm font-semibold">{slot.label}</Label>
|
||||||
|
{slots.length > 2 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeSlot(slot.id)}
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
|
<Select
|
||||||
|
value={slot.propertyType}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSlot(slot.id, 'propertyType', e.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{VALUATION_PROPERTY_TYPES.map((t) => (
|
||||||
|
<option key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Diện tích (m²)"
|
||||||
|
value={slot.area}
|
||||||
|
onChange={(e) => updateSlot(slot.id, 'area', e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Quận/Huyện"
|
||||||
|
value={slot.district}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSlot(slot.id, 'district', e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={slot.city}
|
||||||
|
onChange={(e) => updateSlot(slot.id, 'city', e.target.value)}
|
||||||
|
>
|
||||||
|
{CITIES.map((c) => (
|
||||||
|
<option key={c.value} value={c.value}>
|
||||||
|
{c.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Phòng ngủ"
|
||||||
|
value={slot.bedrooms}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSlot(slot.id, 'bedrooms', e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{slots.length < 5 && (
|
||||||
|
<Button type="button" variant="outline" onClick={addSlot}>
|
||||||
|
<Plus className="mr-1.5 h-4 w-4" />
|
||||||
|
Thêm BĐS
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={handleCompare}
|
||||||
|
disabled={
|
||||||
|
batchMutation.isPending ||
|
||||||
|
slots.filter((s) => s.area && s.district).length < 2
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{batchMutation.isPending
|
||||||
|
? 'Đang so sánh...'
|
||||||
|
: 'So sánh ngay'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Comparison results */}
|
||||||
|
{results && results.length > 0 && (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{results.map((result, i) => {
|
||||||
|
const isBest = bestValue && result.id === bestValue.id;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={result.id || i}
|
||||||
|
className={isBest ? 'border-primary ring-2 ring-primary/20' : ''}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{slots[i]?.label ?? `BĐS ${i + 1}`}
|
||||||
|
</CardTitle>
|
||||||
|
{isBest && (
|
||||||
|
<Badge variant="success" className="text-xs">
|
||||||
|
Giá/m² tốt nhất
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
{slots[i]?.district}, {slots[i]?.city}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-primary">
|
||||||
|
{formatPrice(result.estimatedPriceVND)} VNĐ
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{formatPricePerM2(result.pricePerM2)}/m²
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Độ tin cậy</span>
|
||||||
|
<span className={`font-semibold ${getConfidenceColor(result.confidence)}`}>
|
||||||
|
{Math.round(result.confidence * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-1.5 rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className={`h-1.5 rounded-full ${
|
||||||
|
result.confidence >= 0.8
|
||||||
|
? 'bg-green-500'
|
||||||
|
: result.confidence >= 0.5
|
||||||
|
? 'bg-yellow-500'
|
||||||
|
: 'bg-red-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${result.confidence * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Khoảng giá: {formatPrice(result.priceRangeLow)} -{' '}
|
||||||
|
{formatPrice(result.priceRangeHigh)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result.priceDrivers.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 pt-1">
|
||||||
|
{result.priceDrivers.slice(0, 3).map((d) => (
|
||||||
|
<Badge
|
||||||
|
key={d.feature}
|
||||||
|
variant={getConfidenceVariant(
|
||||||
|
d.direction === 'positive' ? 1 : 0,
|
||||||
|
)}
|
||||||
|
className="text-[10px]"
|
||||||
|
>
|
||||||
|
{d.direction === 'positive' ? '+' : '-'}
|
||||||
|
{Math.abs(d.impact).toFixed(0)}% {d.feature}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{batchMutation.isError && (
|
||||||
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
|
Không thể so sánh. Vui lòng thử lại sau.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
apps/web/components/valuation/value-drivers-chart.tsx
Normal file
147
apps/web/components/valuation/value-drivers-chart.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Cell,
|
||||||
|
ReferenceLine,
|
||||||
|
} from 'recharts';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import type { PriceDriver } from '@/lib/valuation-api';
|
||||||
|
|
||||||
|
interface ValueDriversChartProps {
|
||||||
|
drivers: PriceDriver[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEATURE_LABELS: Record<string, string> = {
|
||||||
|
area_m2: 'Diện tích',
|
||||||
|
avg_price_district_3m_vnd_m2: 'Giá TB khu vực',
|
||||||
|
property_type_encoded: 'Loại BĐS',
|
||||||
|
distance_to_cbd_km: 'Khoảng cách trung tâm',
|
||||||
|
renovation_score: 'Cải tạo',
|
||||||
|
building_age_years: 'Tuổi công trình',
|
||||||
|
has_legal_paper: 'Giấy tờ pháp lý',
|
||||||
|
distance_to_metro_km: 'Khoảng cách metro',
|
||||||
|
interior_quality: 'Nội thất',
|
||||||
|
price_momentum_30d: 'Đà tăng giá 30 ngày',
|
||||||
|
view_quality: 'Chất lượng view',
|
||||||
|
natural_light: 'Ánh sáng tự nhiên',
|
||||||
|
noise_level: 'Mức ồn',
|
||||||
|
flood_zone_risk: 'Nguy cơ ngập',
|
||||||
|
park_occupancy_rate: 'Tỉ lệ lấp đầy',
|
||||||
|
logistics_connectivity_score: 'Kết nối logistics',
|
||||||
|
industry_demand_index: 'Nhu cầu CN',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getFeatureLabel(feature: string): string {
|
||||||
|
return FEATURE_LABELS[feature] || feature.replace(/_/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WaterfallItem {
|
||||||
|
name: string;
|
||||||
|
base: number;
|
||||||
|
value: number;
|
||||||
|
fill: string;
|
||||||
|
importance: number;
|
||||||
|
direction: 'positive' | 'negative';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWaterfallData(drivers: PriceDriver[]): WaterfallItem[] {
|
||||||
|
const sorted = [...drivers].sort(
|
||||||
|
(a, b) => Math.abs(b.impact) - Math.abs(a.impact),
|
||||||
|
);
|
||||||
|
|
||||||
|
let cumulative = 0;
|
||||||
|
return sorted.map((driver) => {
|
||||||
|
const isPositive = driver.direction === 'positive';
|
||||||
|
const absImpact = Math.abs(driver.impact);
|
||||||
|
const item: WaterfallItem = {
|
||||||
|
name: getFeatureLabel(driver.feature),
|
||||||
|
base: isPositive ? cumulative : cumulative - absImpact,
|
||||||
|
value: absImpact,
|
||||||
|
fill: isPositive ? '#22c55e' : '#ef4444',
|
||||||
|
importance: absImpact,
|
||||||
|
direction: driver.direction,
|
||||||
|
};
|
||||||
|
cumulative += isPositive ? absImpact : -absImpact;
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomTooltip({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{ payload: WaterfallItem }>;
|
||||||
|
}) {
|
||||||
|
if (!active || !payload?.[0]) return null;
|
||||||
|
const data = payload[0].payload;
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-popover px-3 py-2 text-sm shadow-md">
|
||||||
|
<p className="font-medium">{data.name}</p>
|
||||||
|
<p className={data.direction === 'positive' ? 'text-green-600' : 'text-red-600'}>
|
||||||
|
{data.direction === 'positive' ? '+' : '-'}
|
||||||
|
{data.importance.toFixed(1)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ValueDriversChart({ drivers }: ValueDriversChartProps) {
|
||||||
|
if (drivers.length === 0) return null;
|
||||||
|
|
||||||
|
const data = buildWaterfallData(drivers);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Yếu tố ảnh hưởng giá</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Biểu đồ thác nước thể hiện mức ảnh hưởng của từng yếu tố
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={Math.max(300, data.length * 44)}>
|
||||||
|
<BarChart
|
||||||
|
data={data}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||||
|
>
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
tickFormatter={(v: number) => `${v.toFixed(0)}%`}
|
||||||
|
domain={['dataMin', 'dataMax']}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="name"
|
||||||
|
width={150}
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<ReferenceLine x={0} stroke="#888" strokeDasharray="3 3" />
|
||||||
|
{/* Invisible base bar for waterfall offset */}
|
||||||
|
<Bar dataKey="base" stackId="waterfall" fill="transparent" />
|
||||||
|
{/* Visible value bar */}
|
||||||
|
<Bar dataKey="value" stackId="waterfall" radius={[0, 4, 4, 0]}>
|
||||||
|
{data.map((entry, index) => (
|
||||||
|
<Cell key={index} fill={entry.fill} fillOpacity={0.8} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
apps/web/lib/hooks/__tests__/use-avm-v2-flag.spec.tsx
Normal file
99
apps/web/lib/hooks/__tests__/use-avm-v2-flag.spec.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const LOCAL_KEY = 'goodgo:avm_v2';
|
||||||
|
|
||||||
|
function installMemoryStorage(): Storage {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
const storage: Storage = {
|
||||||
|
get length() {
|
||||||
|
return store.size;
|
||||||
|
},
|
||||||
|
clear: () => store.clear(),
|
||||||
|
getItem: (k) => (store.has(k) ? store.get(k)! : null),
|
||||||
|
key: (i) => Array.from(store.keys())[i] ?? null,
|
||||||
|
removeItem: (k) => {
|
||||||
|
store.delete(k);
|
||||||
|
},
|
||||||
|
setItem: (k, v) => {
|
||||||
|
store.set(k, String(v));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
vi.stubGlobal('localStorage', storage);
|
||||||
|
Object.defineProperty(window, 'localStorage', {
|
||||||
|
configurable: true,
|
||||||
|
value: storage,
|
||||||
|
});
|
||||||
|
return storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useAvmV2Flag', () => {
|
||||||
|
let storage: Storage;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
storage = installMemoryStorage();
|
||||||
|
window.history.replaceState({}, '', '/');
|
||||||
|
delete (process.env as Record<string, string | undefined>)[
|
||||||
|
'NEXT_PUBLIC_FEATURE_AVM_V2'
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
storage.clear();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false by default when env flag is not set', async () => {
|
||||||
|
const { useAvmV2Flag } = await import('../use-avm-v2-flag');
|
||||||
|
const { result } = renderHook(() => useAvmV2Flag());
|
||||||
|
expect(result.current).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when NEXT_PUBLIC_FEATURE_AVM_V2 is "1"', async () => {
|
||||||
|
process.env['NEXT_PUBLIC_FEATURE_AVM_V2'] = '1';
|
||||||
|
const { useAvmV2Flag } = await import('../use-avm-v2-flag');
|
||||||
|
const { result } = renderHook(() => useAvmV2Flag());
|
||||||
|
expect(result.current).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when NEXT_PUBLIC_FEATURE_AVM_V2 is "true"', async () => {
|
||||||
|
process.env['NEXT_PUBLIC_FEATURE_AVM_V2'] = 'true';
|
||||||
|
const { useAvmV2Flag } = await import('../use-avm-v2-flag');
|
||||||
|
const { result } = renderHook(() => useAvmV2Flag());
|
||||||
|
expect(result.current).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('query param ?avm_v2=1 forces on and persists to localStorage', async () => {
|
||||||
|
window.history.replaceState({}, '', '/?avm_v2=1');
|
||||||
|
const { useAvmV2Flag } = await import('../use-avm-v2-flag');
|
||||||
|
const { result } = renderHook(() => useAvmV2Flag());
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(result.current).toBe(true);
|
||||||
|
expect(storage.getItem(LOCAL_KEY)).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('query param ?avm_v2=0 forces off and persists to localStorage', async () => {
|
||||||
|
process.env['NEXT_PUBLIC_FEATURE_AVM_V2'] = '1';
|
||||||
|
window.history.replaceState({}, '', '/?avm_v2=0');
|
||||||
|
const { useAvmV2Flag } = await import('../use-avm-v2-flag');
|
||||||
|
const { result } = renderHook(() => useAvmV2Flag());
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(result.current).toBe(false);
|
||||||
|
expect(storage.getItem(LOCAL_KEY)).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects localStorage override over env default', async () => {
|
||||||
|
storage.setItem(LOCAL_KEY, '1');
|
||||||
|
const { useAvmV2Flag } = await import('../use-avm-v2-flag');
|
||||||
|
const { result } = renderHook(() => useAvmV2Flag());
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(result.current).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
55
apps/web/lib/hooks/use-avm-v2-flag.ts
Normal file
55
apps/web/lib/hooks/use-avm-v2-flag.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const LOCAL_STORAGE_KEY = 'goodgo:avm_v2';
|
||||||
|
const QUERY_PARAM = 'avm_v2';
|
||||||
|
|
||||||
|
function readEnvDefault(): boolean {
|
||||||
|
const raw = process.env['NEXT_PUBLIC_FEATURE_AVM_V2'];
|
||||||
|
if (!raw) return false;
|
||||||
|
return raw === '1' || raw.toLowerCase() === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOverride(): boolean | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const qp = params.get(QUERY_PARAM);
|
||||||
|
if (qp === '1' || qp === 'true') {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(LOCAL_STORAGE_KEY, '1');
|
||||||
|
} catch {
|
||||||
|
// localStorage may be blocked — ignore
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (qp === '0' || qp === 'false') {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(LOCAL_STORAGE_KEY, '0');
|
||||||
|
} catch {
|
||||||
|
// localStorage may be blocked — ignore
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||||
|
if (stored === '1') return true;
|
||||||
|
if (stored === '0') return false;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAvmV2Flag(): boolean {
|
||||||
|
const [enabled, setEnabled] = useState<boolean>(readEnvDefault());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const override = readOverride();
|
||||||
|
setEnabled(override ?? readEnvDefault());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
59
apps/web/lib/hooks/use-residential-projects-flag.ts
Normal file
59
apps/web/lib/hooks/use-residential-projects-flag.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const LOCAL_STORAGE_KEY = 'goodgo:residential_projects';
|
||||||
|
const QUERY_PARAM = 'residential_projects';
|
||||||
|
|
||||||
|
function readEnvDefault(): boolean {
|
||||||
|
const raw = process.env['NEXT_PUBLIC_FEATURE_RESIDENTIAL_PROJECTS'];
|
||||||
|
if (!raw) return false;
|
||||||
|
return raw === '1' || raw.toLowerCase() === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOverride(): boolean | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const qp = params.get(QUERY_PARAM);
|
||||||
|
if (qp === '1' || qp === 'true') {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(LOCAL_STORAGE_KEY, '1');
|
||||||
|
} catch {
|
||||||
|
// localStorage may be blocked — ignore
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (qp === '0' || qp === 'false') {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(LOCAL_STORAGE_KEY, '0');
|
||||||
|
} catch {
|
||||||
|
// localStorage may be blocked — ignore
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||||
|
if (stored === '1') return true;
|
||||||
|
if (stored === '0') return false;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResidentialProjectsFlag(): boolean {
|
||||||
|
const [enabled, setEnabled] = useState<boolean>(readEnvDefault());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const override = readOverride();
|
||||||
|
setEnabled(override ?? readEnvDefault());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isResidentialProjectsEnabledServer(): boolean {
|
||||||
|
return readEnvDefault();
|
||||||
|
}
|
||||||
41
apps/web/lib/inquiry-store.ts
Normal file
41
apps/web/lib/inquiry-store.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI state for the listing inquiry modal.
|
||||||
|
*
|
||||||
|
* Lives in a Zustand store so that:
|
||||||
|
* - any component (e.g. floating CTAs, sticky "Nhắn tin" bars) can open the
|
||||||
|
* modal without prop drilling through the listing detail tree
|
||||||
|
* - tests and devtools can inspect / drive modal state directly
|
||||||
|
*/
|
||||||
|
export interface InquiryModalTarget {
|
||||||
|
listingId: string;
|
||||||
|
listingTitle: string;
|
||||||
|
sellerName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InquiryModalState {
|
||||||
|
/** Whether the inquiry modal is currently open. */
|
||||||
|
isOpen: boolean;
|
||||||
|
/** The listing being inquired about (null when the modal is closed). */
|
||||||
|
target: InquiryModalTarget | null;
|
||||||
|
|
||||||
|
/** Open the modal for a given listing. */
|
||||||
|
openInquiry: (target: InquiryModalTarget) => void;
|
||||||
|
/** Close the modal and clear the active target. */
|
||||||
|
closeInquiry: () => void;
|
||||||
|
/** Update open state directly (used by Radix onOpenChange). */
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useInquiryStore = create<InquiryModalState>((set) => ({
|
||||||
|
isOpen: false,
|
||||||
|
target: null,
|
||||||
|
openInquiry: (target) => set({ isOpen: true, target }),
|
||||||
|
closeInquiry: () => set({ isOpen: false, target: null }),
|
||||||
|
setOpen: (open) =>
|
||||||
|
set((state) => ({
|
||||||
|
isOpen: open,
|
||||||
|
target: open ? state.target : null,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
174
apps/web/lib/transfer-wizard-store.ts
Normal file
174
apps/web/lib/transfer-wizard-store.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import type { TransferCategory, TransferCondition, TransferPricingSource } from './chuyen-nhuong-api';
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface TransferItemDraft {
|
||||||
|
id: string; // client-side only
|
||||||
|
name: string;
|
||||||
|
brand?: string;
|
||||||
|
modelName?: string;
|
||||||
|
condition: TransferCondition;
|
||||||
|
purchaseYear?: number;
|
||||||
|
originalPriceVND?: number;
|
||||||
|
askingPriceVND: number;
|
||||||
|
quantity: number;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiEstimate {
|
||||||
|
estimatedPriceVND: string;
|
||||||
|
confidence: number;
|
||||||
|
factors: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiEstimateResult {
|
||||||
|
estimates: AiEstimate[];
|
||||||
|
totalEstimateVND: string;
|
||||||
|
avgConfidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransferWizardState {
|
||||||
|
// Step tracking
|
||||||
|
currentStep: number;
|
||||||
|
|
||||||
|
// Step 1: Category
|
||||||
|
category: TransferCategory | null;
|
||||||
|
|
||||||
|
// Step 2: Items
|
||||||
|
items: TransferItemDraft[];
|
||||||
|
|
||||||
|
// Step 2 (premises): Additional fields
|
||||||
|
areaM2?: number;
|
||||||
|
monthlyRentVND?: number;
|
||||||
|
depositMonths?: number;
|
||||||
|
remainingLeaseMo?: number;
|
||||||
|
businessType?: string;
|
||||||
|
footTraffic?: string;
|
||||||
|
|
||||||
|
// Step 3: AI estimate
|
||||||
|
aiEstimate: AiEstimateResult | null;
|
||||||
|
isEstimating: boolean;
|
||||||
|
|
||||||
|
// Step 4: Review & submit
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
address: string;
|
||||||
|
ward: string;
|
||||||
|
district: string;
|
||||||
|
city: string;
|
||||||
|
contactName: string;
|
||||||
|
contactPhone: string;
|
||||||
|
askingPriceVND: number;
|
||||||
|
pricingSource: TransferPricingSource;
|
||||||
|
isNegotiable: boolean;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setStep: (step: number) => void;
|
||||||
|
setCategory: (category: TransferCategory) => void;
|
||||||
|
addItem: (item: Omit<TransferItemDraft, 'id'>) => void;
|
||||||
|
updateItem: (id: string, item: Partial<TransferItemDraft>) => void;
|
||||||
|
removeItem: (id: string) => void;
|
||||||
|
setPremisesFields: (fields: Partial<Pick<TransferWizardState, 'areaM2' | 'monthlyRentVND' | 'depositMonths' | 'remainingLeaseMo' | 'businessType' | 'footTraffic'>>) => void;
|
||||||
|
setAiEstimate: (result: AiEstimateResult | null) => void;
|
||||||
|
setIsEstimating: (loading: boolean) => void;
|
||||||
|
setListingDetails: (details: Partial<Pick<TransferWizardState, 'title' | 'description' | 'address' | 'ward' | 'district' | 'city' | 'contactName' | 'contactPhone' | 'askingPriceVND' | 'pricingSource' | 'isNegotiable'>>) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Initial state ──────────────────────────────────────
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
currentStep: 0,
|
||||||
|
category: null as TransferCategory | null,
|
||||||
|
items: [] as TransferItemDraft[],
|
||||||
|
areaM2: undefined,
|
||||||
|
monthlyRentVND: undefined,
|
||||||
|
depositMonths: undefined,
|
||||||
|
remainingLeaseMo: undefined,
|
||||||
|
businessType: undefined,
|
||||||
|
footTraffic: undefined,
|
||||||
|
aiEstimate: null as AiEstimateResult | null,
|
||||||
|
isEstimating: false,
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
address: '',
|
||||||
|
ward: '',
|
||||||
|
district: '',
|
||||||
|
city: 'Hồ Chí Minh',
|
||||||
|
contactName: '',
|
||||||
|
contactPhone: '',
|
||||||
|
askingPriceVND: 0,
|
||||||
|
pricingSource: 'MANUAL' as TransferPricingSource,
|
||||||
|
isNegotiable: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Store ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
let nextItemId = 1;
|
||||||
|
|
||||||
|
export const useTransferWizardStore = create<TransferWizardState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
|
setStep: (step) => set({ currentStep: step }),
|
||||||
|
|
||||||
|
setCategory: (category) => set({ category }),
|
||||||
|
|
||||||
|
addItem: (item) => {
|
||||||
|
const id = `item-${nextItemId++}`;
|
||||||
|
set((state) => ({ items: [...state.items, { ...item, id }] }));
|
||||||
|
},
|
||||||
|
|
||||||
|
updateItem: (id, updates) =>
|
||||||
|
set((state) => ({
|
||||||
|
items: state.items.map((item) =>
|
||||||
|
item.id === id ? { ...item, ...updates } : item,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
|
||||||
|
removeItem: (id) =>
|
||||||
|
set((state) => ({ items: state.items.filter((item) => item.id !== id) })),
|
||||||
|
|
||||||
|
setPremisesFields: (fields) => set(fields),
|
||||||
|
|
||||||
|
setAiEstimate: (result) => set({ aiEstimate: result }),
|
||||||
|
|
||||||
|
setIsEstimating: (isEstimating) => set({ isEstimating }),
|
||||||
|
|
||||||
|
setListingDetails: (details) => set(details),
|
||||||
|
|
||||||
|
reset: () => {
|
||||||
|
nextItemId = 1;
|
||||||
|
set(initialState);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'goodgo-transfer-wizard',
|
||||||
|
partialize: (state) => ({
|
||||||
|
currentStep: state.currentStep,
|
||||||
|
category: state.category,
|
||||||
|
items: state.items,
|
||||||
|
areaM2: state.areaM2,
|
||||||
|
monthlyRentVND: state.monthlyRentVND,
|
||||||
|
depositMonths: state.depositMonths,
|
||||||
|
remainingLeaseMo: state.remainingLeaseMo,
|
||||||
|
businessType: state.businessType,
|
||||||
|
footTraffic: state.footTraffic,
|
||||||
|
title: state.title,
|
||||||
|
description: state.description,
|
||||||
|
address: state.address,
|
||||||
|
ward: state.ward,
|
||||||
|
district: state.district,
|
||||||
|
city: state.city,
|
||||||
|
contactName: state.contactName,
|
||||||
|
contactPhone: state.contactPhone,
|
||||||
|
askingPriceVND: state.askingPriceVND,
|
||||||
|
pricingSource: state.pricingSource,
|
||||||
|
isNegotiable: state.isNegotiable,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
24
apps/web/lib/validations/inquiry.ts
Normal file
24
apps/web/lib/validations/inquiry.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vietnamese phone number rule:
|
||||||
|
* - 9–11 digits, optional leading +84 or 0.
|
||||||
|
* We keep validation pragmatic: whitespace is stripped, then the remaining
|
||||||
|
* string must be 9–11 digits (country code / leading zero stripped).
|
||||||
|
*/
|
||||||
|
const PHONE_REGEX = /^(?:\+?84|0)?\d{9,11}$/;
|
||||||
|
|
||||||
|
export const inquiryFormSchema = z.object({
|
||||||
|
message: z
|
||||||
|
.string({ error: 'Vui lòng nhập nội dung tin nhắn' })
|
||||||
|
.trim()
|
||||||
|
.min(1, 'Vui lòng nhập nội dung tin nhắn')
|
||||||
|
.max(2000, 'Tin nhắn không được vượt quá 2000 ký tự'),
|
||||||
|
phone: z
|
||||||
|
.string({ error: 'Vui lòng nhập số điện thoại' })
|
||||||
|
.trim()
|
||||||
|
.min(9, 'Vui lòng nhập số điện thoại hợp lệ')
|
||||||
|
.regex(PHONE_REGEX, 'Số điện thoại không hợp lệ'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InquiryFormData = z.infer<typeof inquiryFormSchema>;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user