Compare commits
10 Commits
e18390ead9
...
38b9def99a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38b9def99a | ||
|
|
0f3b4d7b0d | ||
|
|
caa0a58afd | ||
|
|
8c6e3b92d0 | ||
|
|
729afe2db6 | ||
|
|
5731577fa9 | ||
|
|
580eb2a261 | ||
|
|
2c1e3771e9 | ||
|
|
329a821b4a | ||
|
|
5d4ecdeb2f |
@@ -37,6 +37,7 @@
|
||||
"@prisma/client": "^7.7.0",
|
||||
"@sentry/nestjs": "^10.47.0",
|
||||
"@sentry/profiling-node": "^10.47.0",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"@willsoto/nestjs-prometheus": "^6.1.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bullmq": "^5.74.1",
|
||||
|
||||
@@ -8,11 +8,10 @@ import './instrument';
|
||||
|
||||
import { RequestMethod, ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { IoAdapter } from '@nestjs/platform-socket.io';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import helmet from 'helmet';
|
||||
import { LoggerService, validateEnv } from '@modules/shared';
|
||||
import { LoggerService, RedisIoAdapter, validateEnv } from '@modules/shared';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
@@ -60,7 +59,11 @@ async function bootstrap() {
|
||||
});
|
||||
|
||||
// ── WebSocket Adapter (Socket.IO) ──
|
||||
app.useWebSocketAdapter(new IoAdapter(app));
|
||||
// Redis pub/sub fan-out for multi-instance broadcasts; falls back to the
|
||||
// in-memory IoAdapter when Redis is unreachable (single-node / local dev).
|
||||
const wsAdapter = new RedisIoAdapter(app);
|
||||
await wsAdapter.connectToRedis();
|
||||
app.useWebSocketAdapter(wsAdapter);
|
||||
|
||||
// ── Security Headers (Helmet) ──
|
||||
app.use(
|
||||
|
||||
@@ -2,13 +2,19 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Ip,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam, ApiQuery } from '@nestjs/swagger';
|
||||
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
||||
import {
|
||||
AdminFeatureListingCommand,
|
||||
type AdminFeatureListingResult,
|
||||
} from '@modules/listings';
|
||||
import { ApproveKycCommand } from '../../application/commands/approve-kyc/approve-kyc.command';
|
||||
import { type ApproveKycResult } from '../../application/commands/approve-kyc/approve-kyc.handler';
|
||||
import { ApproveListingCommand } from '../../application/commands/approve-listing/approve-listing.command';
|
||||
@@ -25,6 +31,7 @@ import {
|
||||
type ModerationQueueResult,
|
||||
type KycQueueResult,
|
||||
} from '../../domain/repositories/admin-query.repository';
|
||||
import { type AdminFeatureListingDto } from '../dto/admin-feature-listing.dto';
|
||||
import { type ApproveKycDto } from '../dto/approve-kyc.dto';
|
||||
import { type ApproveListingDto } from '../dto/approve-listing.dto';
|
||||
import { type BulkModerateDto } from '../dto/bulk-moderate.dto';
|
||||
@@ -105,6 +112,33 @@ export class AdminModerationController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post('listings/:id/feature')
|
||||
@ApiOperation({
|
||||
summary: 'Admin: feature or unfeature a listing manually (audited, no payment)',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'Listing UUID' })
|
||||
@ApiResponse({ status: 201, description: 'Listing featured state updated successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Validation error' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized – missing or invalid JWT' })
|
||||
@ApiResponse({ status: 403, description: 'Forbidden – requires ADMIN role' })
|
||||
async adminFeatureListing(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: AdminFeatureListingDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Ip() ip: string,
|
||||
): Promise<AdminFeatureListingResult> {
|
||||
return this.commandBus.execute(
|
||||
new AdminFeatureListingCommand(
|
||||
id,
|
||||
user.sub,
|
||||
dto.action,
|
||||
dto.durationDays ?? null,
|
||||
dto.reason,
|
||||
ip ?? null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── KYC ──
|
||||
|
||||
@Get('kyc')
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsIn, IsInt, IsOptional, IsString, MinLength, ValidateIf } from 'class-validator';
|
||||
|
||||
const ALLOWED_DURATIONS = [3, 7, 14, 30, 60, 90] as const;
|
||||
export type AdminFeatureDuration = (typeof ALLOWED_DURATIONS)[number];
|
||||
|
||||
export class AdminFeatureListingDto {
|
||||
@ApiProperty({
|
||||
enum: ['feature', 'unfeature'],
|
||||
example: 'feature',
|
||||
description: 'Bật hoặc gỡ tin nổi bật thủ công',
|
||||
})
|
||||
@IsIn(['feature', 'unfeature'])
|
||||
action!: 'feature' | 'unfeature';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ALLOWED_DURATIONS,
|
||||
example: 7,
|
||||
description: 'Số ngày featured (bắt buộc khi action=feature)',
|
||||
})
|
||||
@ValidateIf((o: AdminFeatureListingDto) => o.action === 'feature')
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@IsIn([...ALLOWED_DURATIONS])
|
||||
@IsOptional()
|
||||
durationDays?: AdminFeatureDuration;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'Đền bù lỗi hiển thị featured trong 3 ngày qua',
|
||||
description: 'Lý do cho audit log (tối thiểu 5 ký tự)',
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(5)
|
||||
reason!: string;
|
||||
}
|
||||
@@ -23,7 +23,10 @@ import { PrismaValuationRepository } from './infrastructure/repositories/prisma-
|
||||
import { AI_SERVICE_CLIENT, AiServiceClient } from './infrastructure/services/ai-service.client';
|
||||
import { HttpAVMService } from './infrastructure/services/http-avm.service';
|
||||
import { MarketIndexCronService } from './infrastructure/services/market-index-cron.service';
|
||||
import { NeighborhoodScoreServiceImpl } from './infrastructure/services/neighborhood-score.service';
|
||||
import {
|
||||
HttpNeighborhoodScoreService,
|
||||
PrismaNeighborhoodScoreService,
|
||||
} from './infrastructure/services/neighborhood-score.service';
|
||||
import { PrismaAVMService } from './infrastructure/services/prisma-avm.service';
|
||||
import { AnalyticsController } from './presentation/controllers/analytics.controller';
|
||||
import { AvmController } from './presentation/controllers/avm.controller';
|
||||
@@ -66,8 +69,9 @@ const EventHandlers = [
|
||||
PrismaAVMService,
|
||||
{ provide: AVM_SERVICE, useClass: HttpAVMService },
|
||||
|
||||
// Neighborhood scoring
|
||||
{ provide: NEIGHBORHOOD_SCORE_SERVICE, useClass: NeighborhoodScoreServiceImpl },
|
||||
// Neighborhood scoring: HTTP proxy → Python AI service, falls back to Prisma scoring
|
||||
PrismaNeighborhoodScoreService,
|
||||
{ provide: NEIGHBORHOOD_SCORE_SERVICE, useClass: HttpNeighborhoodScoreService },
|
||||
|
||||
// Cron
|
||||
MarketIndexCronService,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { NeighborhoodScoreServiceImpl } from '../services/neighborhood-score.service';
|
||||
import {
|
||||
HttpNeighborhoodScoreService,
|
||||
NeighborhoodScoreServiceImpl,
|
||||
PrismaNeighborhoodScoreService,
|
||||
} from '../services/neighborhood-score.service';
|
||||
|
||||
describe('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;
|
||||
}
|
||||
|
||||
export interface AiNeighborhoodPOICounts {
|
||||
education: number;
|
||||
healthcare: number;
|
||||
transport: number;
|
||||
shopping: number;
|
||||
greenery: number;
|
||||
safety: number;
|
||||
}
|
||||
|
||||
export interface AiNeighborhoodScoreRequest {
|
||||
district: string;
|
||||
city: string;
|
||||
poi_counts: AiNeighborhoodPOICounts;
|
||||
}
|
||||
|
||||
export interface AiNeighborhoodScoreResponse {
|
||||
district: string;
|
||||
city: string;
|
||||
education_score: number;
|
||||
healthcare_score: number;
|
||||
transport_score: number;
|
||||
shopping_score: number;
|
||||
greenery_score: number;
|
||||
safety_score: number;
|
||||
total_score: number;
|
||||
poi_counts: Record<string, number>;
|
||||
algorithm_version: string;
|
||||
}
|
||||
|
||||
export const AI_SERVICE_CLIENT = Symbol('AI_SERVICE_CLIENT');
|
||||
|
||||
export interface IAiServiceClient {
|
||||
predict(req: AiPredictRequest): Promise<AiPredictResponse>;
|
||||
predictIndustrial(req: AiIndustrialPredictRequest): Promise<AiIndustrialPredictResponse>;
|
||||
moderate(req: AiModerationRequest): Promise<AiModerationResponse>;
|
||||
scoreNeighborhood(req: AiNeighborhoodScoreRequest): Promise<AiNeighborhoodScoreResponse>;
|
||||
isAvailable(): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -124,6 +154,12 @@ export class AiServiceClient implements IAiServiceClient {
|
||||
return this.post<AiModerationResponse>('/moderation/check', req);
|
||||
}
|
||||
|
||||
async scoreNeighborhood(
|
||||
req: AiNeighborhoodScoreRequest,
|
||||
): Promise<AiNeighborhoodScoreResponse> {
|
||||
return this.post<AiNeighborhoodScoreResponse>('/neighborhood/score', req);
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/health`, {
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { type PrismaService, type LoggerService } from '@modules/shared';
|
||||
|
||||
@Injectable()
|
||||
export class AvmRetrainCronService {
|
||||
private readonly aiServiceUrl: string;
|
||||
private readonly aiServiceApiKey: string;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
) {
|
||||
this.aiServiceUrl = process.env['AI_SERVICE_URL'] ?? 'http://localhost:8000';
|
||||
this.aiServiceApiKey = process.env['AI_SERVICE_API_KEY'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Weekly retrain — every Sunday at 3 AM.
|
||||
*
|
||||
* 1. Export training data from database to the AI service
|
||||
* 2. Trigger ensemble retraining via POST /avm/v2/train
|
||||
* 3. Log results (version, metrics)
|
||||
*/
|
||||
@Cron('0 3 * * 0', { name: 'avm-v2-weekly-retrain' })
|
||||
async weeklyRetrain(): Promise<void> {
|
||||
this.logger.log('Starting weekly AVM v2 retrain...', 'AvmRetrainCronService');
|
||||
|
||||
try {
|
||||
// Step 1: Export training data
|
||||
const trainingData = await this.exportTrainingData();
|
||||
if (trainingData.length < 50) {
|
||||
this.logger.warn(
|
||||
`Insufficient training data (${trainingData.length} rows). Skipping retrain.`,
|
||||
'AvmRetrainCronService',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Upload training data to AI service
|
||||
await this.uploadTrainingData(trainingData);
|
||||
|
||||
// Step 3: Trigger retraining
|
||||
const result = await this.triggerRetrain();
|
||||
|
||||
this.logger.log(
|
||||
`AVM v2 retrain completed: version=${result.model_version}, ` +
|
||||
`MAPE=${result.metrics?.mape ?? 'N/A'}%, ` +
|
||||
`samples=${result.training_samples}`,
|
||||
'AvmRetrainCronService',
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`AVM v2 weekly retrain failed: ${(err as Error).message}`,
|
||||
undefined,
|
||||
'AvmRetrainCronService',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export property + listing + market data as training rows.
|
||||
*
|
||||
* Each row maps to the feature columns expected by the Python
|
||||
* AVM v2 training pipeline (see avm_v2_service._prepare_training_data).
|
||||
*/
|
||||
async exportTrainingData(): Promise<TrainingRow[]> {
|
||||
const rows = await this.prisma.$queryRaw<RawTrainingRow[]>`
|
||||
WITH market AS (
|
||||
SELECT
|
||||
mi.district,
|
||||
mi.city,
|
||||
mi."avgPriceM2" AS avg_price_m2,
|
||||
mi."totalListings" AS listing_density,
|
||||
COALESCE(mi."absorptionRate", 0) AS absorption_rate,
|
||||
mi."daysOnMarket" AS dom_avg,
|
||||
COALESCE(mi."yoyChange", 0) AS yoy_change
|
||||
FROM "MarketIndex" mi
|
||||
WHERE mi.period = (
|
||||
SELECT MAX(period) FROM "MarketIndex"
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
p."propertyType"::text AS property_type,
|
||||
p."areaM2" AS area_m2,
|
||||
COALESCE(p.bedrooms, 2) AS rooms,
|
||||
COALESCE(p.floor, 0) AS floor_level,
|
||||
COALESCE(p."totalFloors", p.floors, 0) AS total_floors,
|
||||
COALESCE(p.direction::text, 'unknown') AS direction,
|
||||
CASE
|
||||
WHEN p."totalFloors" > 0 AND p."areaM2" > 0
|
||||
THEN (p."totalFloors"::float * p."areaM2") / NULLIF(p."areaM2", 0)
|
||||
ELSE 1.0
|
||||
END AS floor_ratio,
|
||||
CASE
|
||||
WHEN p."yearBuilt" IS NOT NULL
|
||||
THEN EXTRACT(YEAR FROM NOW())::int - p."yearBuilt"
|
||||
ELSE 5
|
||||
END AS building_age_years,
|
||||
CASE WHEN p.amenities::text ILIKE '%elevator%' THEN 1.0 ELSE 0.0 END AS has_elevator,
|
||||
CASE WHEN p.amenities::text ILIKE '%parking%' THEN 1.0 ELSE 0.0 END AS has_parking,
|
||||
CASE WHEN p.amenities::text ILIKE '%pool%' THEN 1.0 ELSE 0.0 END AS has_pool,
|
||||
CASE
|
||||
WHEN p."legalStatus" IN ('so_do', 'so_hong', 'SO_DO', 'SO_HONG') THEN 1.0
|
||||
ELSE 0.0
|
||||
END AS has_legal_paper,
|
||||
0.5 AS developer_reputation,
|
||||
0.5 AS neighborhood_score,
|
||||
COALESCE(
|
||||
ST_Distance(
|
||||
p.location::geography,
|
||||
ST_SetSRID(ST_MakePoint(106.6297, 10.8231), 4326)::geography
|
||||
) / 1000.0,
|
||||
10.0
|
||||
) AS distance_to_cbd_km,
|
||||
COALESCE(p."metroDistanceM" / 1000.0, 5.0) AS distance_to_metro_km,
|
||||
5.0 AS distance_to_school_km,
|
||||
3.0 AS distance_to_hospital_km,
|
||||
2.0 AS distance_to_park_km,
|
||||
4.0 AS distance_to_mall_km,
|
||||
0.1 AS flood_zone_risk,
|
||||
COALESCE(m.avg_price_m2, 0) AS avg_price_district_3m_vnd_m2,
|
||||
COALESCE(m.listing_density, 0) AS listing_density,
|
||||
COALESCE(m.absorption_rate, 0) AS absorption_rate,
|
||||
COALESCE(m.dom_avg, 30) AS dom_avg,
|
||||
0.0 AS price_momentum_30d,
|
||||
COALESCE(m.yoy_change, 0) AS yoy_change,
|
||||
0.5 AS renovation_score,
|
||||
0.5 AS view_quality,
|
||||
0.5 AS interior_quality,
|
||||
0.3 AS noise_level,
|
||||
0.5 AS natural_light,
|
||||
EXTRACT(MONTH FROM l."publishedAt")::int AS month,
|
||||
p.district AS district,
|
||||
l."priceVND"::float AS price_vnd
|
||||
FROM "Listing" l
|
||||
JOIN "Property" p ON l."propertyId" = p.id
|
||||
LEFT JOIN market m ON m.district = p.district AND m.city = p.city
|
||||
WHERE l.status IN ('ACTIVE', 'SOLD', 'RENTED')
|
||||
AND l."priceVND" > 100000000
|
||||
AND l."publishedAt" IS NOT NULL
|
||||
AND p."areaM2" > 0
|
||||
ORDER BY l."publishedAt" DESC
|
||||
LIMIT 50000
|
||||
`;
|
||||
|
||||
return rows.map((r) => ({
|
||||
property_type: String(r.property_type).toLowerCase(),
|
||||
area_m2: Number(r.area_m2),
|
||||
rooms: Number(r.rooms),
|
||||
floor_level: Number(r.floor_level),
|
||||
total_floors: Number(r.total_floors),
|
||||
direction: String(r.direction).toLowerCase(),
|
||||
floor_ratio: Number(r.floor_ratio),
|
||||
building_age_years: Number(r.building_age_years),
|
||||
has_elevator: Number(r.has_elevator),
|
||||
has_parking: Number(r.has_parking),
|
||||
has_pool: Number(r.has_pool),
|
||||
has_legal_paper: Number(r.has_legal_paper),
|
||||
developer_reputation: Number(r.developer_reputation),
|
||||
neighborhood_score: Number(r.neighborhood_score),
|
||||
distance_to_cbd_km: Number(r.distance_to_cbd_km),
|
||||
distance_to_metro_km: Number(r.distance_to_metro_km),
|
||||
distance_to_school_km: Number(r.distance_to_school_km),
|
||||
distance_to_hospital_km: Number(r.distance_to_hospital_km),
|
||||
distance_to_park_km: Number(r.distance_to_park_km),
|
||||
distance_to_mall_km: Number(r.distance_to_mall_km),
|
||||
flood_zone_risk: Number(r.flood_zone_risk),
|
||||
avg_price_district_3m_vnd_m2: Number(r.avg_price_district_3m_vnd_m2),
|
||||
listing_density: Number(r.listing_density),
|
||||
absorption_rate: Number(r.absorption_rate),
|
||||
dom_avg: Number(r.dom_avg),
|
||||
price_momentum_30d: Number(r.price_momentum_30d),
|
||||
yoy_change: Number(r.yoy_change),
|
||||
renovation_score: Number(r.renovation_score),
|
||||
view_quality: Number(r.view_quality),
|
||||
interior_quality: Number(r.interior_quality),
|
||||
noise_level: Number(r.noise_level),
|
||||
natural_light: Number(r.natural_light),
|
||||
month: Number(r.month),
|
||||
district: String(r.district),
|
||||
price_vnd: Number(r.price_vnd),
|
||||
}));
|
||||
}
|
||||
|
||||
private async uploadTrainingData(rows: TrainingRow[]): Promise<void> {
|
||||
const headers = Object.keys(rows[0]!);
|
||||
const csvLines = [headers.join(',')];
|
||||
for (const row of rows) {
|
||||
csvLines.push(headers.map((h) => String(row[h as keyof TrainingRow])).join(','));
|
||||
}
|
||||
const csv = csvLines.join('\n');
|
||||
|
||||
const url = `${this.aiServiceUrl}/avm/v2/upload-training-data`;
|
||||
const reqHeaders: Record<string, string> = { 'Content-Type': 'text/csv' };
|
||||
if (this.aiServiceApiKey) {
|
||||
reqHeaders['X-API-Key'] = this.aiServiceApiKey;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: reqHeaders,
|
||||
body: csv,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`Training data upload failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Uploaded ${rows.length} training rows to AI service`,
|
||||
'AvmRetrainCronService',
|
||||
);
|
||||
}
|
||||
|
||||
private async triggerRetrain(): Promise<RetrainResult> {
|
||||
const url = `${this.aiServiceUrl}/avm/v2/train`;
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.aiServiceApiKey) {
|
||||
headers['X-API-Key'] = this.aiServiceApiKey;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
optuna_trials: 50,
|
||||
test_size: 0.15,
|
||||
val_size: 0.15,
|
||||
}),
|
||||
signal: AbortSignal.timeout(600_000), // 10 min — training can take a while
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`Retrain request failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<RetrainResult>;
|
||||
}
|
||||
}
|
||||
|
||||
interface RawTrainingRow {
|
||||
property_type: string;
|
||||
area_m2: number;
|
||||
rooms: number;
|
||||
floor_level: number;
|
||||
total_floors: number;
|
||||
direction: string;
|
||||
floor_ratio: number;
|
||||
building_age_years: number;
|
||||
has_elevator: number;
|
||||
has_parking: number;
|
||||
has_pool: number;
|
||||
has_legal_paper: number;
|
||||
developer_reputation: number;
|
||||
neighborhood_score: number;
|
||||
distance_to_cbd_km: number;
|
||||
distance_to_metro_km: number;
|
||||
distance_to_school_km: number;
|
||||
distance_to_hospital_km: number;
|
||||
distance_to_park_km: number;
|
||||
distance_to_mall_km: number;
|
||||
flood_zone_risk: number;
|
||||
avg_price_district_3m_vnd_m2: number;
|
||||
listing_density: number;
|
||||
absorption_rate: number;
|
||||
dom_avg: number;
|
||||
price_momentum_30d: number;
|
||||
yoy_change: number;
|
||||
renovation_score: number;
|
||||
view_quality: number;
|
||||
interior_quality: number;
|
||||
noise_level: number;
|
||||
natural_light: number;
|
||||
month: number;
|
||||
district: string;
|
||||
price_vnd: number;
|
||||
}
|
||||
|
||||
interface TrainingRow extends RawTrainingRow {}
|
||||
|
||||
interface RetrainResult {
|
||||
model_version: string;
|
||||
metrics: {
|
||||
mae: number;
|
||||
mape: number;
|
||||
rmse: number;
|
||||
r2: number;
|
||||
};
|
||||
training_samples: number;
|
||||
validation_samples: number;
|
||||
test_samples: number;
|
||||
best_params: Record<string, unknown>;
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { POIType } from '@prisma/client';
|
||||
import { type PrismaService, type LoggerService } from '@modules/shared';
|
||||
import {
|
||||
type INeighborhoodScoreService,
|
||||
type NeighborhoodScoreResult,
|
||||
} from '../../domain/services/neighborhood-score.service';
|
||||
import {
|
||||
AI_SERVICE_CLIENT,
|
||||
type AiNeighborhoodPOICounts,
|
||||
type IAiServiceClient,
|
||||
} from './ai-service.client';
|
||||
|
||||
/**
|
||||
* Scoring weights for each POI category.
|
||||
* Sum = 100 (total score is 0–100 weighted average).
|
||||
* Mirrors the Python heuristic in libs/ai-services/app/services/neighborhood_service.py.
|
||||
*/
|
||||
const CATEGORY_WEIGHTS = {
|
||||
education: 20,
|
||||
@@ -16,20 +23,20 @@ const CATEGORY_WEIGHTS = {
|
||||
shopping: 15,
|
||||
greenery: 15,
|
||||
safety: 10,
|
||||
};
|
||||
} as const;
|
||||
|
||||
/** POI types grouped by scoring category. */
|
||||
const CATEGORY_POI_TYPES: Record<string, string[]> = {
|
||||
education: ['SCHOOL', 'UNIVERSITY'],
|
||||
healthcare: ['HOSPITAL', 'CLINIC'],
|
||||
transport: ['METRO_STATION', 'BUS_STOP'],
|
||||
shopping: ['MALL', 'MARKET', 'SUPERMARKET'],
|
||||
greenery: ['PARK'],
|
||||
safety: ['POLICE_STATION', 'FIRE_STATION'],
|
||||
const CATEGORY_POI_TYPES: Record<keyof typeof CATEGORY_WEIGHTS, POIType[]> = {
|
||||
education: [POIType.SCHOOL, POIType.UNIVERSITY],
|
||||
healthcare: [POIType.HOSPITAL, POIType.CLINIC],
|
||||
transport: [POIType.METRO_STATION, POIType.BUS_STOP],
|
||||
shopping: [POIType.MALL, POIType.MARKET, POIType.SUPERMARKET],
|
||||
greenery: [POIType.PARK],
|
||||
safety: [POIType.POLICE_STATION, POIType.FIRE_STATION],
|
||||
};
|
||||
|
||||
/** Max count per category that yields a 10/10 score. */
|
||||
const MAX_COUNTS: Record<string, number> = {
|
||||
const MAX_COUNTS: Record<keyof typeof CATEGORY_WEIGHTS, number> = {
|
||||
education: 15,
|
||||
healthcare: 8,
|
||||
transport: 12,
|
||||
@@ -38,8 +45,11 @@ const MAX_COUNTS: Record<string, number> = {
|
||||
safety: 4,
|
||||
};
|
||||
|
||||
type CategoryKey = keyof typeof CATEGORY_WEIGHTS;
|
||||
const CATEGORY_KEYS = Object.keys(CATEGORY_WEIGHTS) as CategoryKey[];
|
||||
|
||||
@Injectable()
|
||||
export class NeighborhoodScoreServiceImpl implements INeighborhoodScoreService {
|
||||
export class PrismaNeighborhoodScoreService implements INeighborhoodScoreService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
@@ -52,91 +62,179 @@ export class NeighborhoodScoreServiceImpl implements INeighborhoodScoreService {
|
||||
|
||||
if (!existing) return null;
|
||||
|
||||
return {
|
||||
district: existing.district,
|
||||
city: existing.city,
|
||||
educationScore: existing.educationScore,
|
||||
healthcareScore: existing.healthcareScore,
|
||||
transportScore: existing.transportScore,
|
||||
shoppingScore: existing.shoppingScore,
|
||||
greeneryScore: existing.greeneryScore,
|
||||
safetyScore: existing.safetyScore,
|
||||
totalScore: existing.totalScore,
|
||||
poiCounts: existing.poiCounts as Record<string, number>,
|
||||
calculatedAt: existing.calculatedAt,
|
||||
};
|
||||
return mapRecord(existing);
|
||||
}
|
||||
|
||||
async calculateAndSave(district: string, city: string): Promise<NeighborhoodScoreResult> {
|
||||
// Count POIs per category for this district
|
||||
const poiCounts: Record<string, number> = {};
|
||||
const categoryScores: Record<string, number> = {};
|
||||
const counts = await countPOIs(this.prisma, district, city);
|
||||
const subScores = scoreFromCounts(counts);
|
||||
const totalScore = weightedTotal(subScores);
|
||||
|
||||
for (const [category, poiTypes] of Object.entries(CATEGORY_POI_TYPES)) {
|
||||
const count = await this.prisma.pOI.count({
|
||||
const result = await upsertScore(this.prisma, district, city, subScores, totalScore, counts);
|
||||
this.logger.log(
|
||||
`Neighborhood score (prisma) calculated: ${district}, ${city} → total=${result.totalScore}`,
|
||||
'NeighborhoodScoreService',
|
||||
);
|
||||
return mapRecord(result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the Python AI service to compute scores; falls back to local Prisma scoring
|
||||
* when the service is unavailable or the call times out. Persists to NeighborhoodScore.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HttpNeighborhoodScoreService implements INeighborhoodScoreService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
@Inject(AI_SERVICE_CLIENT) private readonly aiClient: IAiServiceClient,
|
||||
private readonly fallback: PrismaNeighborhoodScoreService,
|
||||
) {}
|
||||
|
||||
async getScore(district: string, city: string): Promise<NeighborhoodScoreResult | null> {
|
||||
return this.fallback.getScore(district, city);
|
||||
}
|
||||
|
||||
async calculateAndSave(district: string, city: string): Promise<NeighborhoodScoreResult> {
|
||||
const counts = await countPOIs(this.prisma, district, city);
|
||||
|
||||
try {
|
||||
const aiResult = await this.aiClient.scoreNeighborhood({
|
||||
district,
|
||||
city,
|
||||
poi_counts: counts,
|
||||
});
|
||||
|
||||
const subScores: Record<CategoryKey, number> = {
|
||||
education: aiResult.education_score,
|
||||
healthcare: aiResult.healthcare_score,
|
||||
transport: aiResult.transport_score,
|
||||
shopping: aiResult.shopping_score,
|
||||
greenery: aiResult.greenery_score,
|
||||
safety: aiResult.safety_score,
|
||||
};
|
||||
|
||||
const result = await upsertScore(
|
||||
this.prisma,
|
||||
district,
|
||||
city,
|
||||
subScores,
|
||||
aiResult.total_score,
|
||||
counts,
|
||||
);
|
||||
this.logger.log(
|
||||
`Neighborhood score (ai=${aiResult.algorithm_version}): ${district}, ${city} → total=${result.totalScore}`,
|
||||
'NeighborhoodScoreService',
|
||||
);
|
||||
return mapRecord(result);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`AI neighborhood score unavailable, falling back to prisma scoring: ${(err as Error).message}`,
|
||||
'NeighborhoodScoreService',
|
||||
);
|
||||
return this.fallback.calculateAndSave(district, city);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function countPOIs(
|
||||
prisma: PrismaService,
|
||||
district: string,
|
||||
city: string,
|
||||
): Promise<AiNeighborhoodPOICounts> {
|
||||
const entries = await Promise.all(
|
||||
CATEGORY_KEYS.map(async (cat) => {
|
||||
const count = await prisma.pOI.count({
|
||||
where: {
|
||||
district,
|
||||
city,
|
||||
type: { in: poiTypes as any },
|
||||
type: { in: CATEGORY_POI_TYPES[cat] },
|
||||
},
|
||||
});
|
||||
return [cat, count] as const;
|
||||
}),
|
||||
);
|
||||
|
||||
poiCounts[category] = count;
|
||||
// Score 0–10: linear scale capped at MAX_COUNTS
|
||||
const maxCount = MAX_COUNTS[category]!;
|
||||
categoryScores[category] = Math.min(10, (count / maxCount) * 10);
|
||||
}
|
||||
|
||||
// Weighted total score (0–100)
|
||||
const totalScore = Object.entries(CATEGORY_WEIGHTS).reduce((sum, [cat, weight]) => {
|
||||
return sum + (categoryScores[cat]! * weight) / 10;
|
||||
}, 0);
|
||||
|
||||
const result = await this.prisma.neighborhoodScore.upsert({
|
||||
where: { district_city: { district, city } },
|
||||
create: {
|
||||
district,
|
||||
city,
|
||||
educationScore: categoryScores['education']!,
|
||||
healthcareScore: categoryScores['healthcare']!,
|
||||
transportScore: categoryScores['transport']!,
|
||||
shoppingScore: categoryScores['shopping']!,
|
||||
greeneryScore: categoryScores['greenery']!,
|
||||
safetyScore: categoryScores['safety']!,
|
||||
totalScore: Math.round(totalScore * 10) / 10,
|
||||
poiCounts,
|
||||
calculatedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
educationScore: categoryScores['education']!,
|
||||
healthcareScore: categoryScores['healthcare']!,
|
||||
transportScore: categoryScores['transport']!,
|
||||
shoppingScore: categoryScores['shopping']!,
|
||||
greeneryScore: categoryScores['greenery']!,
|
||||
safetyScore: categoryScores['safety']!,
|
||||
totalScore: Math.round(totalScore * 10) / 10,
|
||||
poiCounts,
|
||||
calculatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Neighborhood score calculated: ${district}, ${city} → total=${result.totalScore}`,
|
||||
'NeighborhoodScoreService',
|
||||
);
|
||||
|
||||
return {
|
||||
district: result.district,
|
||||
city: result.city,
|
||||
educationScore: result.educationScore,
|
||||
healthcareScore: result.healthcareScore,
|
||||
transportScore: result.transportScore,
|
||||
shoppingScore: result.shoppingScore,
|
||||
greeneryScore: result.greeneryScore,
|
||||
safetyScore: result.safetyScore,
|
||||
totalScore: result.totalScore,
|
||||
poiCounts: result.poiCounts as Record<string, number>,
|
||||
calculatedAt: result.calculatedAt,
|
||||
};
|
||||
}
|
||||
return Object.fromEntries(entries) as unknown as AiNeighborhoodPOICounts;
|
||||
}
|
||||
|
||||
function scoreFromCounts(counts: AiNeighborhoodPOICounts): Record<CategoryKey, number> {
|
||||
return Object.fromEntries(
|
||||
CATEGORY_KEYS.map((cat) => {
|
||||
const raw = counts[cat] ?? 0;
|
||||
const max = MAX_COUNTS[cat];
|
||||
return [cat, Math.min(10, (raw / max) * 10)];
|
||||
}),
|
||||
) as Record<CategoryKey, number>;
|
||||
}
|
||||
|
||||
function weightedTotal(subScores: Record<CategoryKey, number>): number {
|
||||
const sum = CATEGORY_KEYS.reduce(
|
||||
(acc, cat) => acc + (subScores[cat] * CATEGORY_WEIGHTS[cat]) / 10,
|
||||
0,
|
||||
);
|
||||
return Math.round(sum * 10) / 10;
|
||||
}
|
||||
|
||||
async function upsertScore(
|
||||
prisma: PrismaService,
|
||||
district: string,
|
||||
city: string,
|
||||
subScores: Record<CategoryKey, number>,
|
||||
totalScore: number,
|
||||
counts: AiNeighborhoodPOICounts,
|
||||
) {
|
||||
const calculatedAt = new Date();
|
||||
const data = {
|
||||
educationScore: subScores.education,
|
||||
healthcareScore: subScores.healthcare,
|
||||
transportScore: subScores.transport,
|
||||
shoppingScore: subScores.shopping,
|
||||
greeneryScore: subScores.greenery,
|
||||
safetyScore: subScores.safety,
|
||||
totalScore,
|
||||
poiCounts: counts as unknown as Record<string, number>,
|
||||
calculatedAt,
|
||||
};
|
||||
|
||||
return prisma.neighborhoodScore.upsert({
|
||||
where: { district_city: { district, city } },
|
||||
create: { district, city, ...data },
|
||||
update: data,
|
||||
});
|
||||
}
|
||||
|
||||
function mapRecord(record: {
|
||||
district: string;
|
||||
city: string;
|
||||
educationScore: number;
|
||||
healthcareScore: number;
|
||||
transportScore: number;
|
||||
shoppingScore: number;
|
||||
greeneryScore: number;
|
||||
safetyScore: number;
|
||||
totalScore: number;
|
||||
poiCounts: unknown;
|
||||
calculatedAt: Date;
|
||||
}): NeighborhoodScoreResult {
|
||||
return {
|
||||
district: record.district,
|
||||
city: record.city,
|
||||
educationScore: record.educationScore,
|
||||
healthcareScore: record.healthcareScore,
|
||||
transportScore: record.transportScore,
|
||||
shoppingScore: record.shoppingScore,
|
||||
greeneryScore: record.greeneryScore,
|
||||
safetyScore: record.safetyScore,
|
||||
totalScore: record.totalScore,
|
||||
poiCounts: record.poiCounts as Record<string, number>,
|
||||
calculatedAt: record.calculatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use HttpNeighborhoodScoreService (binds AI proxy + prisma fallback).
|
||||
* Kept exported for backward compatibility with callers/tests.
|
||||
*/
|
||||
export { PrismaNeighborhoodScoreService as NeighborhoodScoreServiceImpl };
|
||||
|
||||
@@ -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({});
|
||||
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', () => {
|
||||
|
||||
@@ -2,6 +2,19 @@ export { ListingsModule } from './listings.module';
|
||||
export { ListingEntity, type ListingProps } from './domain/entities/listing.entity';
|
||||
export { ListingCreatedEvent } from './domain/events/listing-created.event';
|
||||
export { ModerateListingCommand } from './application/commands/moderate-listing/moderate-listing.command';
|
||||
export {
|
||||
AdminFeatureListingCommand,
|
||||
type AdminFeatureAction,
|
||||
} from './application/commands/admin-feature-listing/admin-feature-listing.command';
|
||||
export { type AdminFeatureListingResult } from './application/commands/admin-feature-listing/admin-feature-listing.handler';
|
||||
export {
|
||||
PromoteFeaturedListingCommand,
|
||||
type PromoteFeaturedDuration,
|
||||
} from './application/commands/promote-featured-listing/promote-featured-listing.command';
|
||||
export {
|
||||
type PromoteFeaturedListingResult,
|
||||
FEATURED_LISTINGS_PROMOTED_METRIC,
|
||||
} from './application/commands/promote-featured-listing/promote-featured-listing.handler';
|
||||
export { LISTING_REPOSITORY, IListingRepository, type ListingSearchParams, type PaginatedResult } from './domain/repositories/listing.repository';
|
||||
export { ListingPriceChangedEvent } from './domain/events/listing-price-changed.event';
|
||||
export { ListingSoldEvent } from './domain/events/listing-sold.event';
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
import { AdminFeatureListingHandler } from './application/commands/admin-feature-listing/admin-feature-listing.handler';
|
||||
import { CreateListingHandler } from './application/commands/create-listing/create-listing.handler';
|
||||
import { FeatureListingHandler } from './application/commands/feature-listing/feature-listing.handler';
|
||||
import { ModerateListingHandler } from './application/commands/moderate-listing/moderate-listing.handler';
|
||||
import { PromoteFeaturedListingHandler } from './application/commands/promote-featured-listing/promote-featured-listing.handler';
|
||||
import { UpdateListingHandler } from './application/commands/update-listing/update-listing.handler';
|
||||
import { UpdateListingStatusHandler } from './application/commands/update-listing-status/update-listing-status.handler';
|
||||
import { UploadMediaHandler } from './application/commands/upload-media/upload-media.handler';
|
||||
@@ -28,6 +30,8 @@ import { ListingsController } from './presentation/controllers/listings.controll
|
||||
const CommandHandlers = [
|
||||
CreateListingHandler,
|
||||
FeatureListingHandler,
|
||||
PromoteFeaturedListingHandler,
|
||||
AdminFeatureListingHandler,
|
||||
UpdateListingHandler,
|
||||
UpdateListingStatusHandler,
|
||||
UploadMediaHandler,
|
||||
|
||||
@@ -33,6 +33,8 @@ import type { CreateListingResult } from '../../application/commands/create-list
|
||||
import { FeatureListingCommand } from '../../application/commands/feature-listing/feature-listing.command';
|
||||
import type { FeatureListingResult } from '../../application/commands/feature-listing/feature-listing.handler';
|
||||
import { ModerateListingCommand } from '../../application/commands/moderate-listing/moderate-listing.command';
|
||||
import { PromoteFeaturedListingCommand } from '../../application/commands/promote-featured-listing/promote-featured-listing.command';
|
||||
import type { PromoteFeaturedListingResult } from '../../application/commands/promote-featured-listing/promote-featured-listing.handler';
|
||||
import { UpdateListingCommand } from '../../application/commands/update-listing/update-listing.command';
|
||||
import type { UpdateListingResult } from '../../application/commands/update-listing/update-listing.handler';
|
||||
import { UpdateListingStatusCommand } from '../../application/commands/update-listing-status/update-listing-status.command';
|
||||
@@ -47,6 +49,7 @@ import type { PaginatedResult } from '../../domain/repositories/listing.reposito
|
||||
import type { CreateListingDto } from '../dto/create-listing.dto';
|
||||
import type { FeatureListingDto } from '../dto/feature-listing.dto';
|
||||
import type { ModerateListingDto } from '../dto/moderate-listing.dto';
|
||||
import type { PromoteFeaturedListingDto } from '../dto/promote-featured-listing.dto';
|
||||
import { type SearchListingsDto } from '../dto/search-listings.dto';
|
||||
import type { UpdateListingStatusDto } from '../dto/update-listing-status.dto';
|
||||
import type { UpdateListingDto } from '../dto/update-listing.dto';
|
||||
@@ -319,4 +322,28 @@ export class ListingsController {
|
||||
new FeatureListingCommand(id, user.sub, dto.package, dto.provider, dto.returnUrl, ip),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({
|
||||
summary: 'Promote a listing via subscription entitlement (no payment)',
|
||||
description:
|
||||
'Sử dụng quota `featured_listings_promoted` của subscription để bật featured không qua thanh toán.',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'Listing UUID' })
|
||||
@ApiResponse({ status: 201, description: 'Listing promoted successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid duration or listing not ACTIVE' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 403, description: 'Not owner/agent or quota exhausted' })
|
||||
@UseGuards(JwtAuthGuard, QuotaGuard)
|
||||
@RequireQuota('featured_listings_promoted')
|
||||
@Post(':id/promote')
|
||||
async promoteListing(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: PromoteFeaturedListingDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<PromoteFeaturedListingResult> {
|
||||
return this.commandBus.execute(
|
||||
new PromoteFeaturedListingCommand(id, user.sub, dto.durationDays),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsIn, IsInt } from 'class-validator';
|
||||
import { type PromoteFeaturedDuration } from '../../application/commands/promote-featured-listing/promote-featured-listing.command';
|
||||
|
||||
const ALLOWED_DURATIONS: readonly number[] = [3, 7, 14, 30];
|
||||
|
||||
export class PromoteFeaturedListingDto {
|
||||
@ApiProperty({
|
||||
enum: ALLOWED_DURATIONS,
|
||||
example: 7,
|
||||
description: 'Số ngày đẩy nổi bật (dùng quota subscription, không phát sinh thanh toán)',
|
||||
})
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@IsIn([...ALLOWED_DURATIONS])
|
||||
durationDays!: PromoteFeaturedDuration;
|
||||
}
|
||||
@@ -9,6 +9,11 @@ describe('MetricsService', () => {
|
||||
let mockSearchQueriesCounter: { inc: ReturnType<typeof vi.fn> };
|
||||
let mockRequestDurationHistogram: { observe: ReturnType<typeof vi.fn> };
|
||||
let mockHttpRequestsCounter: { inc: ReturnType<typeof vi.fn> };
|
||||
let mockWsConnectedClientsGauge: {
|
||||
inc: ReturnType<typeof vi.fn>;
|
||||
set: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockWsMessagesCounter: { inc: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockListingsCreatedCounter = { inc: vi.fn() };
|
||||
@@ -17,6 +22,8 @@ describe('MetricsService', () => {
|
||||
mockSearchQueriesCounter = { inc: vi.fn() };
|
||||
mockRequestDurationHistogram = { observe: vi.fn() };
|
||||
mockHttpRequestsCounter = { inc: vi.fn() };
|
||||
mockWsConnectedClientsGauge = { inc: vi.fn(), set: vi.fn() };
|
||||
mockWsMessagesCounter = { inc: vi.fn() };
|
||||
|
||||
service = new MetricsService(
|
||||
mockListingsCreatedCounter as unknown as Counter,
|
||||
@@ -25,6 +32,8 @@ describe('MetricsService', () => {
|
||||
mockSearchQueriesCounter as unknown as Counter,
|
||||
mockRequestDurationHistogram as unknown as Histogram,
|
||||
mockHttpRequestsCounter as unknown as Counter,
|
||||
mockWsConnectedClientsGauge as unknown as Gauge,
|
||||
mockWsMessagesCounter as unknown as Counter,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -102,4 +111,41 @@ describe('MetricsService', () => {
|
||||
expect.objectContaining({ status_code: '503' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('recordWsConnection increments the connected-clients gauge with +1 on connect', () => {
|
||||
service.recordWsConnection('/notifications', 1);
|
||||
|
||||
expect(mockWsConnectedClientsGauge.inc).toHaveBeenCalledWith(
|
||||
{ namespace: '/notifications' },
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('recordWsConnection decrements the connected-clients gauge with -1 on disconnect', () => {
|
||||
service.recordWsConnection('/notifications', -1);
|
||||
|
||||
expect(mockWsConnectedClientsGauge.inc).toHaveBeenCalledWith(
|
||||
{ namespace: '/notifications' },
|
||||
-1,
|
||||
);
|
||||
});
|
||||
|
||||
it('setWsConnectedClients sets the gauge for a namespace', () => {
|
||||
service.setWsConnectedClients('/notifications', 0);
|
||||
|
||||
expect(mockWsConnectedClientsGauge.set).toHaveBeenCalledWith(
|
||||
{ namespace: '/notifications' },
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('recordWsMessage increments the messages counter with namespace/event/direction', () => {
|
||||
service.recordWsMessage('/notifications', 'notification:new', 'out');
|
||||
|
||||
expect(mockWsMessagesCounter.inc).toHaveBeenCalledWith({
|
||||
namespace: '/notifications',
|
||||
event: 'notification:new',
|
||||
direction: 'out',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
GOODGO_SEARCH_QUERIES_TOTAL,
|
||||
GOODGO_API_REQUEST_DURATION,
|
||||
HTTP_REQUESTS_TOTAL,
|
||||
GOODGO_WS_CONNECTED_CLIENTS,
|
||||
GOODGO_WS_MESSAGES_TOTAL,
|
||||
WEB_VITALS_LCP,
|
||||
WEB_VITALS_FCP,
|
||||
WEB_VITALS_CLS,
|
||||
@@ -31,6 +33,10 @@ export class MetricsService {
|
||||
private readonly requestDurationHistogram: Histogram,
|
||||
@InjectMetric(HTTP_REQUESTS_TOTAL)
|
||||
private readonly httpRequestsCounter: Counter,
|
||||
@InjectMetric(GOODGO_WS_CONNECTED_CLIENTS)
|
||||
private readonly wsConnectedClientsGauge: Gauge,
|
||||
@InjectMetric(GOODGO_WS_MESSAGES_TOTAL)
|
||||
private readonly wsMessagesCounter: Counter,
|
||||
@InjectMetric(WEB_VITALS_LCP)
|
||||
private readonly lcpHistogram: Histogram,
|
||||
@InjectMetric(WEB_VITALS_FCP)
|
||||
@@ -81,6 +87,25 @@ export class MetricsService {
|
||||
this.httpRequestsCounter.inc(labels);
|
||||
}
|
||||
|
||||
/** Track a WebSocket client connection (++) or disconnection (--). */
|
||||
recordWsConnection(namespace: string, delta: 1 | -1): void {
|
||||
this.wsConnectedClientsGauge.inc({ namespace }, delta);
|
||||
}
|
||||
|
||||
/** Reset the connected-clients gauge for a namespace (e.g. on shutdown). */
|
||||
setWsConnectedClients(namespace: string, count: number): void {
|
||||
this.wsConnectedClientsGauge.set({ namespace }, count);
|
||||
}
|
||||
|
||||
/** Record a WebSocket message emitted/received on a given event. */
|
||||
recordWsMessage(
|
||||
namespace: string,
|
||||
event: string,
|
||||
direction: 'in' | 'out',
|
||||
): void {
|
||||
this.wsMessagesCounter.inc({ namespace, event, direction });
|
||||
}
|
||||
|
||||
/** Map metric name → the correct histogram. */
|
||||
private readonly vitalHistograms: Record<string, Histogram | undefined> = {};
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ export const DB_QUERY_DURATION = 'db_query_duration_seconds';
|
||||
export const DB_POOL_ACTIVE_CONNECTIONS = 'db_pool_active_connections';
|
||||
export const SEARCH_QUERY_DURATION = 'search_query_duration_seconds';
|
||||
|
||||
// ── WebSocket Metrics ──
|
||||
export const GOODGO_WS_CONNECTED_CLIENTS = 'goodgo_ws_connected_clients';
|
||||
export const GOODGO_WS_MESSAGES_TOTAL = 'goodgo_ws_messages_total';
|
||||
|
||||
// ── Web Vitals / RUM Metrics ──
|
||||
export const WEB_VITALS_LCP = 'goodgo_web_vitals_lcp_seconds';
|
||||
export const WEB_VITALS_FCP = 'goodgo_web_vitals_fcp_seconds';
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
DB_QUERY_DURATION,
|
||||
DB_POOL_ACTIVE_CONNECTIONS,
|
||||
SEARCH_QUERY_DURATION,
|
||||
GOODGO_WS_CONNECTED_CLIENTS,
|
||||
GOODGO_WS_MESSAGES_TOTAL,
|
||||
WEB_VITALS_LCP,
|
||||
WEB_VITALS_FCP,
|
||||
WEB_VITALS_CLS,
|
||||
@@ -83,6 +85,18 @@ import { HttpMetricsInterceptor } from './presentation/interceptors/http-metrics
|
||||
labelNames: ['plan'],
|
||||
}),
|
||||
|
||||
// ── WebSocket Metrics ──
|
||||
makeGaugeProvider({
|
||||
name: GOODGO_WS_CONNECTED_CLIENTS,
|
||||
help: 'Number of active WebSocket clients',
|
||||
labelNames: ['namespace'],
|
||||
}),
|
||||
makeCounterProvider({
|
||||
name: GOODGO_WS_MESSAGES_TOTAL,
|
||||
help: 'Total number of WebSocket messages emitted/received',
|
||||
labelNames: ['namespace', 'event', 'direction'],
|
||||
}),
|
||||
|
||||
// ── Services & Interceptors ──
|
||||
MetricsService,
|
||||
HttpMetricsInterceptor,
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { ListingApprovedEvent } from '@modules/admin/domain/events/listing-approved.event';
|
||||
import { InquiryReadEvent } from '@modules/inquiries/domain/events/inquiry-read.event';
|
||||
import { ListingPriceChangedEvent } from '@modules/listings/domain/events/listing-price-changed.event';
|
||||
import {
|
||||
ResidentialInquiryReplyListener,
|
||||
ResidentialNewListingInProjectListener,
|
||||
ResidentialPriceDropListener,
|
||||
} from '../listeners/residential-events.listener';
|
||||
|
||||
function createMockPrisma() {
|
||||
return {
|
||||
listing: { findUnique: vi.fn() },
|
||||
savedSearch: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
};
|
||||
}
|
||||
|
||||
function createMockGateway() {
|
||||
return {
|
||||
emitResidentialEvent: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockLogger() {
|
||||
return { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
}
|
||||
|
||||
describe('ResidentialPriceDropListener', () => {
|
||||
let listener: ResidentialPriceDropListener;
|
||||
let prisma: ReturnType<typeof createMockPrisma>;
|
||||
let gateway: ReturnType<typeof createMockGateway>;
|
||||
let logger: ReturnType<typeof createMockLogger>;
|
||||
|
||||
const listing = {
|
||||
id: 'listing-1',
|
||||
sellerId: 'seller-1',
|
||||
transactionType: 'SALE',
|
||||
priceVND: 2_000_000_000n,
|
||||
property: {
|
||||
title: 'Căn hộ 2PN Quận 7',
|
||||
propertyType: 'APARTMENT',
|
||||
areaM2: 70,
|
||||
bedrooms: 2,
|
||||
district: 'Quận 7',
|
||||
city: 'Hồ Chí Minh',
|
||||
projectDevelopmentId: null,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = createMockPrisma();
|
||||
gateway = createMockGateway();
|
||||
logger = createMockLogger();
|
||||
listener = new ResidentialPriceDropListener(
|
||||
prisma as any,
|
||||
gateway as any,
|
||||
logger as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('emits residential:price-drop to each user with a matching saved search', async () => {
|
||||
prisma.listing.findUnique.mockResolvedValue(listing);
|
||||
prisma.savedSearch.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'ss-1',
|
||||
userId: 'user-1',
|
||||
name: 'Quận 7 căn hộ',
|
||||
filters: { city: 'Hồ Chí Minh', district: 'Quận 7', priceMax: 3_000_000_000 },
|
||||
},
|
||||
{
|
||||
id: 'ss-2',
|
||||
userId: 'user-2',
|
||||
name: 'Quận 1',
|
||||
filters: { district: 'Quận 1' },
|
||||
},
|
||||
]);
|
||||
|
||||
const event = new ListingPriceChangedEvent('listing-1', 2_500_000_000n, 2_000_000_000n);
|
||||
await listener.handle(event);
|
||||
|
||||
expect(gateway.emitResidentialEvent).toHaveBeenCalledTimes(1);
|
||||
expect(gateway.emitResidentialEvent).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
'residential:price-drop',
|
||||
expect.objectContaining({
|
||||
listingId: 'listing-1',
|
||||
savedSearchId: 'ss-1',
|
||||
oldPrice: '2500000000',
|
||||
newPrice: '2000000000',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not emit when the new price is not lower than the old price', async () => {
|
||||
const event = new ListingPriceChangedEvent('listing-1', 1_000_000_000n, 1_200_000_000n);
|
||||
await listener.handle(event);
|
||||
|
||||
expect(prisma.listing.findUnique).not.toHaveBeenCalled();
|
||||
expect(gateway.emitResidentialEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips saved searches owned by the listing seller', async () => {
|
||||
prisma.listing.findUnique.mockResolvedValue(listing);
|
||||
prisma.savedSearch.findMany.mockResolvedValue([
|
||||
{ id: 'ss-self', userId: 'seller-1', name: 'mine', filters: {} },
|
||||
]);
|
||||
|
||||
const event = new ListingPriceChangedEvent('listing-1', 2_500_000_000n, 2_000_000_000n);
|
||||
await listener.handle(event);
|
||||
|
||||
expect(gateway.emitResidentialEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows infrastructure errors without throwing', async () => {
|
||||
prisma.listing.findUnique.mockRejectedValue(new Error('db down'));
|
||||
|
||||
const event = new ListingPriceChangedEvent('listing-1', 2_000_000_000n, 1_000_000_000n);
|
||||
await expect(listener.handle(event)).resolves.not.toThrow();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ResidentialNewListingInProjectListener', () => {
|
||||
let listener: ResidentialNewListingInProjectListener;
|
||||
let prisma: ReturnType<typeof createMockPrisma>;
|
||||
let gateway: ReturnType<typeof createMockGateway>;
|
||||
let logger: ReturnType<typeof createMockLogger>;
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = createMockPrisma();
|
||||
gateway = createMockGateway();
|
||||
logger = createMockLogger();
|
||||
listener = new ResidentialNewListingInProjectListener(
|
||||
prisma as any,
|
||||
gateway as any,
|
||||
logger as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('emits residential:new-listing-in-project to users tracking the project', async () => {
|
||||
prisma.listing.findUnique.mockResolvedValue({
|
||||
id: 'listing-9',
|
||||
sellerId: 'seller-9',
|
||||
priceVND: 3_500_000_000n,
|
||||
property: {
|
||||
title: 'Vinhomes Grand Park S5.02',
|
||||
district: 'Quận 9',
|
||||
city: 'Hồ Chí Minh',
|
||||
projectDevelopmentId: 'project-vgp',
|
||||
},
|
||||
});
|
||||
prisma.savedSearch.findMany.mockResolvedValue([
|
||||
{ id: 'ss-tracker', userId: 'user-10', name: 'VGP', filters: { projectId: 'project-vgp' } },
|
||||
{ id: 'ss-other', userId: 'user-11', name: 'khác', filters: { projectId: 'project-other' } },
|
||||
{ id: 'ss-no-project', userId: 'user-12', name: 'no-project', filters: {} },
|
||||
]);
|
||||
|
||||
const event = new ListingApprovedEvent('listing-9', 'admin-1');
|
||||
await listener.handle(event);
|
||||
|
||||
expect(gateway.emitResidentialEvent).toHaveBeenCalledTimes(1);
|
||||
expect(gateway.emitResidentialEvent).toHaveBeenCalledWith(
|
||||
'user-10',
|
||||
'residential:new-listing-in-project',
|
||||
expect.objectContaining({
|
||||
listingId: 'listing-9',
|
||||
projectId: 'project-vgp',
|
||||
price: '3500000000',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not emit when the listing has no linked project', async () => {
|
||||
prisma.listing.findUnique.mockResolvedValue({
|
||||
id: 'listing-9',
|
||||
sellerId: 'seller-9',
|
||||
priceVND: 1n,
|
||||
property: { title: 't', district: 'd', city: 'c', projectDevelopmentId: null },
|
||||
});
|
||||
|
||||
const event = new ListingApprovedEvent('listing-9', 'admin-1');
|
||||
await listener.handle(event);
|
||||
|
||||
expect(prisma.savedSearch.findMany).not.toHaveBeenCalled();
|
||||
expect(gateway.emitResidentialEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ResidentialInquiryReplyListener', () => {
|
||||
let listener: ResidentialInquiryReplyListener;
|
||||
let gateway: ReturnType<typeof createMockGateway>;
|
||||
let logger: ReturnType<typeof createMockLogger>;
|
||||
|
||||
beforeEach(() => {
|
||||
gateway = createMockGateway();
|
||||
logger = createMockLogger();
|
||||
listener = new ResidentialInquiryReplyListener(gateway as any, logger as any);
|
||||
});
|
||||
|
||||
it('emits residential:inquiry-reply to the inquiry author', async () => {
|
||||
const event = new InquiryReadEvent('inq-1', 'listing-1', 'user-author');
|
||||
|
||||
await listener.handle(event);
|
||||
|
||||
expect(gateway.emitResidentialEvent).toHaveBeenCalledWith(
|
||||
'user-author',
|
||||
'residential:inquiry-reply',
|
||||
expect.objectContaining({
|
||||
inquiryId: 'inq-1',
|
||||
listingId: 'listing-1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('swallows emission errors without throwing', async () => {
|
||||
gateway.emitResidentialEvent.mockImplementation(() => {
|
||||
throw new Error('server error');
|
||||
});
|
||||
const event = new InquiryReadEvent('inq-2', 'listing-2', 'user-2');
|
||||
|
||||
await expect(listener.handle(event)).resolves.not.toThrow();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
import { EventsHandler, type IEventHandler } from '@nestjs/cqrs';
|
||||
import { ListingApprovedEvent } from '@modules/admin';
|
||||
import { InquiryReadEvent } from '@modules/inquiries';
|
||||
import { ListingPriceChangedEvent } from '@modules/listings';
|
||||
import { type LoggerService, type PrismaService } from '@modules/shared';
|
||||
import { type NotificationsGateway } from '../../presentation/gateways/notifications.gateway';
|
||||
|
||||
const CONTEXT = 'ResidentialEventsListener';
|
||||
|
||||
/**
|
||||
* Shape of the `filters` JSON column on `SavedSearch`. Matches fields
|
||||
* consumed by the saved-search alert matcher. Anything else is ignored.
|
||||
*/
|
||||
interface SavedSearchFilters {
|
||||
transactionType?: string;
|
||||
propertyType?: string;
|
||||
projectId?: string;
|
||||
district?: string;
|
||||
city?: string;
|
||||
priceMin?: number;
|
||||
priceMax?: number;
|
||||
areaMin?: number;
|
||||
areaMax?: number;
|
||||
bedrooms?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fans residential domain events out as Socket.IO events on the
|
||||
* `/notifications` namespace so subscribed users get live updates
|
||||
* without waiting for the email/push pipeline.
|
||||
*
|
||||
* Three WS events are emitted:
|
||||
* • `residential:price-drop` — listing price lowered and matches an
|
||||
* alert-enabled saved search.
|
||||
* • `residential:new-listing-in-project` — approved listing lives in
|
||||
* a project that the user tracks via `filters.projectId`.
|
||||
* • `residential:inquiry-reply` — the listing owner/agent marked the
|
||||
* user's inquiry as read, signalling that a reply is incoming.
|
||||
*
|
||||
* Redis pub/sub fan-out is handled by {@link RedisIoAdapter}, so the
|
||||
* broadcast reaches the user's socket regardless of which API pod
|
||||
* holds the connection.
|
||||
*/
|
||||
@EventsHandler(ListingPriceChangedEvent)
|
||||
export class ResidentialPriceDropListener
|
||||
implements IEventHandler<ListingPriceChangedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly gateway: NotificationsGateway,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async handle(event: ListingPriceChangedEvent): Promise<void> {
|
||||
if (event.newPrice >= event.oldPrice) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const listing = await this.prisma.listing.findUnique({
|
||||
where: { id: event.aggregateId },
|
||||
include: { property: true },
|
||||
});
|
||||
if (!listing || !listing.property) return;
|
||||
|
||||
const savedSearches = await this.prisma.savedSearch.findMany({
|
||||
where: { alertEnabled: true },
|
||||
select: { id: true, userId: true, name: true, filters: true },
|
||||
});
|
||||
|
||||
let matchCount = 0;
|
||||
for (const search of savedSearches) {
|
||||
if (search.userId === listing.sellerId) continue;
|
||||
|
||||
const filters = normalizeFilters(search.filters);
|
||||
if (!matchesFilters(listing, listing.property, filters)) continue;
|
||||
|
||||
this.gateway.emitResidentialEvent(search.userId, 'residential:price-drop', {
|
||||
listingId: listing.id,
|
||||
savedSearchId: search.id,
|
||||
savedSearchName: search.name,
|
||||
title: listing.property.title,
|
||||
oldPrice: event.oldPrice.toString(),
|
||||
newPrice: event.newPrice.toString(),
|
||||
district: listing.property.district,
|
||||
city: listing.property.city,
|
||||
occurredAt: event.occurredAt.toISOString(),
|
||||
});
|
||||
matchCount++;
|
||||
}
|
||||
|
||||
if (matchCount > 0) {
|
||||
this.logger.log(
|
||||
`Emitted residential:price-drop to ${matchCount} users for listing ${listing.id}`,
|
||||
CONTEXT,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Price-drop WS emission failed for listing ${event.aggregateId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
CONTEXT,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventsHandler(ListingApprovedEvent)
|
||||
export class ResidentialNewListingInProjectListener
|
||||
implements IEventHandler<ListingApprovedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly gateway: NotificationsGateway,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async handle(event: ListingApprovedEvent): Promise<void> {
|
||||
try {
|
||||
const listing = await this.prisma.listing.findUnique({
|
||||
where: { id: event.aggregateId },
|
||||
include: { property: true },
|
||||
});
|
||||
if (!listing || !listing.property?.projectDevelopmentId) return;
|
||||
|
||||
const projectId = listing.property.projectDevelopmentId;
|
||||
|
||||
const savedSearches = await this.prisma.savedSearch.findMany({
|
||||
where: { alertEnabled: true },
|
||||
select: { id: true, userId: true, name: true, filters: true },
|
||||
});
|
||||
|
||||
let matchCount = 0;
|
||||
for (const search of savedSearches) {
|
||||
if (search.userId === listing.sellerId) continue;
|
||||
|
||||
const filters = normalizeFilters(search.filters);
|
||||
if (filters.projectId !== projectId) continue;
|
||||
|
||||
this.gateway.emitResidentialEvent(
|
||||
search.userId,
|
||||
'residential:new-listing-in-project',
|
||||
{
|
||||
listingId: listing.id,
|
||||
projectId,
|
||||
savedSearchId: search.id,
|
||||
savedSearchName: search.name,
|
||||
title: listing.property.title,
|
||||
price: listing.priceVND.toString(),
|
||||
district: listing.property.district,
|
||||
city: listing.property.city,
|
||||
occurredAt: event.occurredAt.toISOString(),
|
||||
},
|
||||
);
|
||||
matchCount++;
|
||||
}
|
||||
|
||||
if (matchCount > 0) {
|
||||
this.logger.log(
|
||||
`Emitted residential:new-listing-in-project to ${matchCount} users for project ${projectId}`,
|
||||
CONTEXT,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`New-listing-in-project WS emission failed for listing ${event.aggregateId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
CONTEXT,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventsHandler(InquiryReadEvent)
|
||||
export class ResidentialInquiryReplyListener
|
||||
implements IEventHandler<InquiryReadEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly gateway: NotificationsGateway,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async handle(event: InquiryReadEvent): Promise<void> {
|
||||
try {
|
||||
this.gateway.emitResidentialEvent(event.userId, 'residential:inquiry-reply', {
|
||||
inquiryId: event.aggregateId,
|
||||
listingId: event.listingId,
|
||||
occurredAt: event.occurredAt.toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Inquiry-reply WS emission failed for inquiry ${event.aggregateId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
CONTEXT,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────
|
||||
* Private helpers
|
||||
* ──────────────────────────────────────────── */
|
||||
|
||||
function normalizeFilters(raw: unknown): SavedSearchFilters {
|
||||
if (!raw || typeof raw !== 'object') return {};
|
||||
return raw as SavedSearchFilters;
|
||||
}
|
||||
|
||||
function matchesFilters(
|
||||
listing: { transactionType: string; priceVND: bigint; sellerId: string },
|
||||
property: {
|
||||
propertyType: string;
|
||||
areaM2: number;
|
||||
bedrooms: number | null;
|
||||
district: string;
|
||||
city: string;
|
||||
},
|
||||
filters: SavedSearchFilters,
|
||||
): boolean {
|
||||
if (filters.transactionType && filters.transactionType !== listing.transactionType) return false;
|
||||
if (filters.propertyType && filters.propertyType !== property.propertyType) return false;
|
||||
if (filters.district && filters.district !== property.district) return false;
|
||||
if (filters.city && filters.city !== property.city) return false;
|
||||
|
||||
const price = Number(listing.priceVND);
|
||||
if (filters.priceMin !== undefined && price < Number(filters.priceMin)) return false;
|
||||
if (filters.priceMax !== undefined && price > Number(filters.priceMax)) return false;
|
||||
if (filters.areaMin !== undefined && property.areaM2 < Number(filters.areaMin)) return false;
|
||||
if (filters.areaMax !== undefined && property.areaM2 > Number(filters.areaMax)) return false;
|
||||
if (
|
||||
filters.bedrooms !== undefined &&
|
||||
property.bedrooms !== null &&
|
||||
property.bedrooms < Number(filters.bedrooms)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -14,3 +14,9 @@ export {
|
||||
NotificationChannel,
|
||||
ALL_CHANNELS,
|
||||
} from './value-objects/notification-channel.vo';
|
||||
export {
|
||||
SMS_NOTIFICATION_CHANNEL,
|
||||
type NotificationChannelPort,
|
||||
type SendChannelMessageDto,
|
||||
type SendChannelMessageResult,
|
||||
} from './ports/notification-channel.port';
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { type NotificationChannel } from '../value-objects/notification-channel.vo';
|
||||
|
||||
export interface SendChannelMessageDto {
|
||||
recipient: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
templateKey: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SendChannelMessageResult {
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
export interface NotificationChannelPort {
|
||||
readonly channel: NotificationChannel;
|
||||
readonly isAvailable: boolean;
|
||||
send(dto: SendChannelMessageDto): Promise<SendChannelMessageResult>;
|
||||
}
|
||||
|
||||
export const SMS_NOTIFICATION_CHANNEL = Symbol('SMS_NOTIFICATION_CHANNEL');
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
SMS_RATE_LIMIT_BUCKETS,
|
||||
SmsRateLimiterService,
|
||||
} from '../services/sms-rate-limiter.service';
|
||||
|
||||
describe('SmsRateLimiterService', () => {
|
||||
let mockRedis: { getClient: ReturnType<typeof vi.fn> };
|
||||
let mockClient: { eval: ReturnType<typeof vi.fn> };
|
||||
let mockLogger: {
|
||||
log: ReturnType<typeof vi.fn>;
|
||||
warn: ReturnType<typeof vi.fn>;
|
||||
error: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let service: SmsRateLimiterService;
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = { eval: vi.fn() };
|
||||
mockRedis = { getClient: vi.fn().mockReturnValue(mockClient) };
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
service = new SmsRateLimiterService(mockRedis as any, mockLogger as any);
|
||||
});
|
||||
|
||||
it('allows the request when Lua script reports under limit', async () => {
|
||||
mockClient.eval.mockResolvedValue([1, 0]);
|
||||
|
||||
const decision = await service.check('+84901234567', 'otp');
|
||||
|
||||
expect(decision.allowed).toBe(true);
|
||||
expect(decision.current).toBe(1);
|
||||
expect(decision.limit).toBe(SMS_RATE_LIMIT_BUCKETS.otp.limit);
|
||||
expect(decision.retryAfterSeconds).toBe(0);
|
||||
expect(decision.bucket).toBe('otp');
|
||||
});
|
||||
|
||||
it('blocks the request and returns retryAfter when limit reached', async () => {
|
||||
mockClient.eval.mockResolvedValue([SMS_RATE_LIMIT_BUCKETS.otp.limit, 12_345]);
|
||||
|
||||
const decision = await service.check('+84901234567', 'otp');
|
||||
|
||||
expect(decision.allowed).toBe(false);
|
||||
expect(decision.retryAfterSeconds).toBeGreaterThanOrEqual(1);
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('SMS rate limit hit'),
|
||||
'SmsRateLimiterService',
|
||||
);
|
||||
});
|
||||
|
||||
it('namespaces the key per phone and bucket', async () => {
|
||||
mockClient.eval.mockResolvedValue([1, 0]);
|
||||
|
||||
await service.check('+84901234567', 'transactional');
|
||||
|
||||
expect(mockClient.eval).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
1,
|
||||
'sms_rate_limit:transactional:+84901234567',
|
||||
expect.any(Number),
|
||||
SMS_RATE_LIMIT_BUCKETS.transactional.windowSeconds * 1000,
|
||||
SMS_RATE_LIMIT_BUCKETS.transactional.limit,
|
||||
expect.any(String),
|
||||
SMS_RATE_LIMIT_BUCKETS.transactional.windowSeconds,
|
||||
);
|
||||
});
|
||||
|
||||
it('fails open when Redis throws (allows the send, logs warning)', async () => {
|
||||
mockClient.eval.mockRejectedValue(new Error('redis down'));
|
||||
|
||||
const decision = await service.check('+84901234567', 'otpHourly');
|
||||
|
||||
expect(decision.allowed).toBe(true);
|
||||
expect(decision.current).toBe(0);
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Redis error'),
|
||||
'SmsRateLimiterService',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,23 @@
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import { DomainException } from '@modules/shared';
|
||||
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', () => {
|
||||
let service: StringeeSmsService;
|
||||
let mockLogger: {
|
||||
@@ -7,10 +25,12 @@ describe('StringeeSmsService', () => {
|
||||
warn: ReturnType<typeof vi.fn>;
|
||||
error: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockRateLimiter: { check: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -56,6 +76,12 @@ describe('StringeeSmsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotificationChannelPort contract', () => {
|
||||
it('exposes the SMS channel identifier', () => {
|
||||
expect(service.channel).toBe('SMS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendNotification', () => {
|
||||
beforeEach(() => {
|
||||
process.env['STRINGEE_API_KEY'] = 'test-api-key';
|
||||
@@ -183,7 +209,7 @@ describe('StringeeSmsService', () => {
|
||||
});
|
||||
|
||||
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(
|
||||
uninitService.sendNotification({ to: '0901234567', message: 'Test' }),
|
||||
@@ -217,5 +243,117 @@ describe('StringeeSmsService', () => {
|
||||
expect(callBody.text).toContain('GoodGo');
|
||||
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 { FcmService, type SendPushDto } from './services/fcm.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 { ZaloOaService, type SendZaloOaDto, type ZaloOaMessageResult } from './services/zalo-oa.service';
|
||||
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 { type LoggerService } from '@modules/shared';
|
||||
import { HttpStatus, Injectable, type OnModuleInit } from '@nestjs/common';
|
||||
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 {
|
||||
to: string;
|
||||
message: string;
|
||||
/** Rate-limit bucket; defaults to `transactional`. OTP flows should pass `otp`. */
|
||||
bucket?: SmsRateLimitBucket;
|
||||
}
|
||||
|
||||
export interface SendOtpDto {
|
||||
@@ -13,15 +25,26 @@ export interface SendOtpDto {
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
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()
|
||||
export class StringeeSmsService implements OnModuleInit {
|
||||
export class StringeeSmsService implements OnModuleInit, NotificationChannelPort {
|
||||
readonly channel: NotificationChannel = 'SMS';
|
||||
|
||||
private apiKey = '';
|
||||
private brandName = '';
|
||||
private initialized = false;
|
||||
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 {
|
||||
this.apiKey = process.env['STRINGEE_API_KEY'] ?? '';
|
||||
@@ -46,26 +69,63 @@ export class StringeeSmsService implements OnModuleInit {
|
||||
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.`;
|
||||
return this.sendWithRetry({ to: dto.to, message });
|
||||
return this.dispatch({ to: dto.to, message, bucket: 'otp' });
|
||||
}
|
||||
|
||||
async sendNotification(dto: SendSmsDto): Promise<{ messageId: string }> {
|
||||
return this.sendWithRetry(dto);
|
||||
async sendNotification(dto: SendSmsDto): Promise<SendChannelMessageResult> {
|
||||
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) {
|
||||
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;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const result = await this.send(dto);
|
||||
return result;
|
||||
return await this.postToStringee(phone, message);
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
@@ -87,13 +147,11 @@ export class StringeeSmsService implements OnModuleInit {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
private async send(dto: SendSmsDto): Promise<{ messageId: string }> {
|
||||
const phone = this.normalizePhone(dto.to);
|
||||
|
||||
private async postToStringee(phone: string, message: string): Promise<SendChannelMessageResult> {
|
||||
const body = {
|
||||
from: { type: 'sms', number: this.brandName, alias: this.brandName },
|
||||
to: [{ type: 'sms', number: phone }],
|
||||
text: dto.message,
|
||||
text: message,
|
||||
};
|
||||
|
||||
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 };
|
||||
|
||||
// Stringee returns r=0 on success
|
||||
if (data.r !== undefined && data.r !== 0) {
|
||||
throw new Error(`Stringee SMS rejected (code ${data.r}): ${data.message ?? 'Unknown reason'}`);
|
||||
}
|
||||
@@ -127,10 +184,6 @@ export class StringeeSmsService implements OnModuleInit {
|
||||
return { messageId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize VN phone numbers to E.164 format (+84...).
|
||||
* Accepts: 0901234567, +84901234567, 84901234567
|
||||
*/
|
||||
private normalizePhone(phone: string): string {
|
||||
const cleaned = phone.replace(/[\s\-()]/g, '');
|
||||
|
||||
@@ -146,6 +199,10 @@ export class StringeeSmsService implements OnModuleInit {
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]*>/g, '').trim();
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { AuthModule } from '@modules/auth';
|
||||
import { MetricsModule } from '@modules/metrics';
|
||||
import { SendNotificationHandler } from './application/commands/send-notification/send-notification.handler';
|
||||
import { AgentVerifiedListener } from './application/listeners/agent-verified.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 { PhoneChangeRequestedListener } from './application/listeners/phone-change-requested.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 { SubscriptionExpiringListener } from './application/listeners/subscription-expiring.listener';
|
||||
import { SubscriptionRenewedListener } from './application/listeners/subscription-renewed.listener';
|
||||
import { UserKycUpdatedListener } from './application/listeners/user-kyc-updated.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_REPOSITORY } from './domain/repositories/notification.repository';
|
||||
import { PrismaNotificationPreferenceRepository } from './infrastructure/repositories/prisma-notification-preference.repository';
|
||||
import { PrismaNotificationRepository } from './infrastructure/repositories/prisma-notification.repository';
|
||||
import { EmailService } from './infrastructure/services/email.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 { TemplateService } from './infrastructure/services/template.service';
|
||||
import { ZaloOaService } from './infrastructure/services/zalo-oa.service';
|
||||
@@ -50,10 +58,13 @@ const EventListeners = [
|
||||
UserKycUpdatedListener,
|
||||
EmailChangeRequestedListener,
|
||||
PhoneChangeRequestedListener,
|
||||
ResidentialPriceDropListener,
|
||||
ResidentialNewListingInProjectListener,
|
||||
ResidentialInquiryReplyListener,
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [CqrsModule, AuthModule],
|
||||
imports: [CqrsModule, AuthModule, MetricsModule],
|
||||
controllers: [NotificationsController, ZaloOaWebhookController],
|
||||
providers: [
|
||||
// Repositories
|
||||
@@ -63,7 +74,9 @@ const EventListeners = [
|
||||
// Services
|
||||
EmailService,
|
||||
FcmService,
|
||||
SmsRateLimiterService,
|
||||
StringeeSmsService,
|
||||
{ provide: SMS_NOTIFICATION_CHANNEL, useExisting: StringeeSmsService },
|
||||
ZaloOaService,
|
||||
TemplateService,
|
||||
|
||||
@@ -76,6 +89,15 @@ const EventListeners = [
|
||||
// Event Listeners
|
||||
...EventListeners,
|
||||
],
|
||||
exports: [EmailService, FcmService, StringeeSmsService, ZaloOaService, TemplateService, NotificationsGateway],
|
||||
exports: [
|
||||
EmailService,
|
||||
FcmService,
|
||||
SmsRateLimiterService,
|
||||
StringeeSmsService,
|
||||
SMS_NOTIFICATION_CHANNEL,
|
||||
ZaloOaService,
|
||||
TemplateService,
|
||||
NotificationsGateway,
|
||||
],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
|
||||
@@ -36,6 +36,11 @@ describe('NotificationsGateway', () => {
|
||||
getClient: 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: {
|
||||
to: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
@@ -53,11 +58,17 @@ describe('NotificationsGateway', () => {
|
||||
getClient: vi.fn().mockReturnValue({ exists: vi.fn().mockResolvedValue(0), incr: vi.fn() }),
|
||||
};
|
||||
mockNotificationRepo = { countUnreadByUserId: vi.fn().mockResolvedValue(3) };
|
||||
mockMetrics = {
|
||||
recordWsConnection: vi.fn(),
|
||||
setWsConnectedClients: vi.fn(),
|
||||
recordWsMessage: vi.fn(),
|
||||
};
|
||||
|
||||
gateway = new NotificationsGateway(
|
||||
mockTokenService as any,
|
||||
mockLogger as any,
|
||||
mockRedisService as any,
|
||||
mockMetrics as any,
|
||||
mockNotificationRepo as any,
|
||||
);
|
||||
|
||||
@@ -74,6 +85,14 @@ describe('NotificationsGateway', () => {
|
||||
'NotificationsGateway',
|
||||
);
|
||||
});
|
||||
|
||||
it('resets the WS connected-clients gauge to 0', () => {
|
||||
gateway.afterInit();
|
||||
expect(mockMetrics.setWsConnectedClients).toHaveBeenCalledWith(
|
||||
'/notifications',
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleConnection', () => {
|
||||
@@ -152,6 +171,28 @@ describe('NotificationsGateway', () => {
|
||||
expect(mockNotificationRepo.countUnreadByUserId).toHaveBeenCalledWith('user-1');
|
||||
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', () => {
|
||||
@@ -183,6 +224,24 @@ describe('NotificationsGateway', () => {
|
||||
// No prior connection — should not throw
|
||||
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', () => {
|
||||
@@ -273,4 +332,25 @@ describe('NotificationsGateway', () => {
|
||||
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
|
||||
import { TokenService, type JwtPayload } from '@modules/auth';
|
||||
// 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 type { NotificationSentEvent } from '../../domain/events/notification-sent.event';
|
||||
import {
|
||||
@@ -24,6 +26,20 @@ const UNREAD_COUNT_KEY = (userId: string) => `notifications:unread:${userId}`;
|
||||
/** TTL for the cached unread count (1 hour). */
|
||||
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({
|
||||
namespace: '/notifications',
|
||||
cors: {
|
||||
@@ -32,6 +48,10 @@ const UNREAD_COUNT_TTL = 3600;
|
||||
.map((o) => o.trim()),
|
||||
credentials: true,
|
||||
},
|
||||
pingInterval: WS_PING_INTERVAL_MS,
|
||||
pingTimeout: WS_PING_TIMEOUT_MS,
|
||||
connectTimeout: WS_CONNECT_TIMEOUT_MS,
|
||||
transports: ['websocket', 'polling'],
|
||||
})
|
||||
export class NotificationsGateway
|
||||
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
|
||||
@@ -46,12 +66,17 @@ export class NotificationsGateway
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly logger: LoggerService,
|
||||
private readonly redisService: RedisService,
|
||||
private readonly metrics: MetricsService,
|
||||
@Inject(NOTIFICATION_REPOSITORY)
|
||||
private readonly notificationRepo: INotificationRepository,
|
||||
) {}
|
||||
|
||||
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);
|
||||
client.emit('notification:unread-count', { unreadCount });
|
||||
|
||||
this.metrics.recordWsConnection(NAMESPACE_LABEL, 1);
|
||||
this.metrics.recordWsMessage(
|
||||
NAMESPACE_LABEL,
|
||||
'notification:unread-count',
|
||||
'out',
|
||||
);
|
||||
|
||||
this.logger.debug(
|
||||
`WS connected: user=${payload.sub} socket=${client.id}`,
|
||||
'NotificationsGateway',
|
||||
@@ -107,6 +139,8 @@ export class NotificationsGateway
|
||||
this.userSockets.delete(userId);
|
||||
}
|
||||
}
|
||||
// Only decrement if the socket completed auth (we tracked it).
|
||||
this.metrics.recordWsConnection(NAMESPACE_LABEL, -1);
|
||||
}
|
||||
this.logger.debug(
|
||||
`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
|
||||
* ──────────────────────────────────────────── */
|
||||
|
||||
@@ -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];
|
||||
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) {
|
||||
filters.push(`city:=${query.city}`);
|
||||
}
|
||||
if (query.featured === true) {
|
||||
filters.push(`isFeatured:=1`);
|
||||
} else if (query.featured === false) {
|
||||
filters.push(`isFeatured:=0`);
|
||||
}
|
||||
|
||||
const searchParams = {
|
||||
query: query.query,
|
||||
@@ -73,6 +78,7 @@ export class SearchPropertiesHandler implements IQueryHandler<SearchPropertiesQu
|
||||
query.areaMax,
|
||||
query.bedrooms,
|
||||
query.sortBy,
|
||||
query.featured === undefined ? undefined : String(query.featured),
|
||||
);
|
||||
|
||||
return this.cache.getOrSet(
|
||||
|
||||
@@ -13,5 +13,6 @@ export class SearchPropertiesQuery {
|
||||
public readonly sortBy?: string,
|
||||
public readonly page?: number,
|
||||
public readonly perPage?: number,
|
||||
public readonly featured?: boolean,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ export class SearchController {
|
||||
dto.sortBy,
|
||||
dto.page,
|
||||
dto.perPage,
|
||||
dto.featured,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsNumber,
|
||||
@@ -78,6 +79,22 @@ export class SearchPropertiesDto {
|
||||
@IsString()
|
||||
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 })
|
||||
@IsOptional()
|
||||
@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 { PrismaService } from './prisma.service';
|
||||
export { RedisService } from './redis.service';
|
||||
export { RedisIoAdapter } from './redis-io.adapter';
|
||||
export { CacheService, CachePrefix, CacheTTL } from './cache.service';
|
||||
export { LoggerService } from './logger.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',
|
||||
analytics_queries: 'maxAnalyticsQueries',
|
||||
media_uploads: 'maxMediaUploads',
|
||||
featured_listings_promoted: 'featuredListingsQuota',
|
||||
};
|
||||
|
||||
@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 { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ComparablesMap } from '@/components/valuation/comparables-map';
|
||||
import { ComparablesTable } from '@/components/valuation/comparables-table';
|
||||
import { ExportPdfButton } from '@/components/valuation/export-pdf-button';
|
||||
import { MarketContextCard } from '@/components/valuation/market-context-card';
|
||||
import { ValuationCompare } from '@/components/valuation/valuation-compare';
|
||||
import { ValuationForm } from '@/components/valuation/valuation-form';
|
||||
import { ValuationHistory } from '@/components/valuation/valuation-history';
|
||||
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 {
|
||||
useValuationPredict,
|
||||
useValuationHistory,
|
||||
@@ -15,7 +20,6 @@ import {
|
||||
} from '@/lib/hooks/use-valuation';
|
||||
import type { ValuationRequest, ValuationResult } from '@/lib/valuation-api';
|
||||
|
||||
// Lazy-load chart component (uses Recharts, no SSR)
|
||||
const ValuationHistoryChart = dynamic(
|
||||
() =>
|
||||
import('@/components/valuation/valuation-history-chart').then(
|
||||
@@ -31,9 +35,13 @@ const ValuationHistoryChart = dynamic(
|
||||
},
|
||||
);
|
||||
|
||||
type ViewMode = 'single' | 'compare';
|
||||
|
||||
export default function ValuationPage() {
|
||||
const avmV2 = useAvmV2Flag();
|
||||
const [historyPage, setHistoryPage] = useState(1);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('single');
|
||||
|
||||
const predictMutation = useValuationPredict();
|
||||
const { data: historyData, isLoading: historyLoading } =
|
||||
@@ -54,15 +62,21 @@ export default function ValuationPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Page header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold sm:text-3xl">Định giá AI</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<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">
|
||||
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>
|
||||
</div>
|
||||
{currentResult && (
|
||||
{currentResult && viewMode === 'single' && (
|
||||
<ExportPdfButton
|
||||
targetSelector="#valuation-results"
|
||||
filename={`dinh-gia-${currentResult.id}`}
|
||||
@@ -70,56 +84,101 @@ export default function ValuationPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Form + Results (left 2 cols) */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<ValuationForm
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={predictMutation.isPending}
|
||||
/>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{predictMutation.isError && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
Không thể định giá. Vui lòng thử lại sau.
|
||||
</div>
|
||||
)}
|
||||
{viewMode === 'compare' && avmV2 ? (
|
||||
<ValuationCompare />
|
||||
) : (
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<ValuationForm
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={predictMutation.isPending}
|
||||
/>
|
||||
|
||||
{currentResult && (
|
||||
<>
|
||||
{/* Main results with confidence badge + driver charts */}
|
||||
<ValuationResults result={currentResult} />
|
||||
{predictMutation.isError && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
Không thể định giá. Vui lòng thử lại sau.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comparables table (TanStack Table) */}
|
||||
{currentResult.comparables.length > 0 && (
|
||||
<ComparablesTable comparables={currentResult.comparables} />
|
||||
)}
|
||||
{currentResult && (
|
||||
<>
|
||||
<ValuationResults result={currentResult} />
|
||||
|
||||
{/* Market context card */}
|
||||
{currentResult.marketContext && (
|
||||
<MarketContextCard context={currentResult.marketContext} />
|
||||
)}
|
||||
|
||||
{/* Valuation history chart */}
|
||||
{currentResult.valuationHistory &&
|
||||
currentResult.valuationHistory.length >= 2 && (
|
||||
<ValuationHistoryChart data={currentResult.valuationHistory} />
|
||||
{avmV2 && currentResult.priceDrivers.length > 0 && (
|
||||
<ValueDriversChart drivers={currentResult.priceDrivers} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* History sidebar (right col) */}
|
||||
<div>
|
||||
<ValuationHistory
|
||||
items={historyData?.data ?? []}
|
||||
total={historyData?.total ?? 0}
|
||||
page={historyPage}
|
||||
onPageChange={setHistoryPage}
|
||||
onSelect={handleSelectHistory}
|
||||
isLoading={historyLoading}
|
||||
/>
|
||||
{currentResult.comparables.length > 0 && (
|
||||
<ComparablesTable comparables={currentResult.comparables} />
|
||||
)}
|
||||
|
||||
{avmV2 && currentResult.comparables.length > 0 && (
|
||||
<ComparablesMap
|
||||
comparables={currentResult.comparables}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentResult.marketContext && (
|
||||
<MarketContextCard context={currentResult.marketContext} />
|
||||
)}
|
||||
|
||||
{currentResult.valuationHistory &&
|
||||
currentResult.valuationHistory.length >= 2 && (
|
||||
<ValuationHistoryChart data={currentResult.valuationHistory} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ValuationHistory
|
||||
items={historyData?.data ?? []}
|
||||
total={historyData?.total ?? 0}
|
||||
page={historyPage}
|
||||
onPageChange={setHistoryPage}
|
||||
onSelect={handleSelectHistory}
|
||||
isLoading={historyLoading}
|
||||
/>
|
||||
</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 { DuAnDetailClient } from '@/components/du-an/du-an-detail-client';
|
||||
import { fetchProjectBySlug } from '@/lib/du-an-server';
|
||||
import { isResidentialProjectsEnabledServer } from '@/lib/hooks/use-residential-projects-flag';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ slug: string; locale: string }>;
|
||||
}
|
||||
|
||||
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 project = await fetchProjectBySlug(slug);
|
||||
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) {
|
||||
if (!isResidentialProjectsEnabledServer()) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { slug } = await params;
|
||||
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 dynamic from 'next/dynamic';
|
||||
import Image from 'next/image';
|
||||
import { notFound } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
import { ProjectCard } from '@/components/du-an/project-card';
|
||||
import { ProjectFilterBar } from '@/components/du-an/project-filter-bar';
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
type SearchProjectsParams,
|
||||
} from '@/lib/du-an-api';
|
||||
import { useProjectsSearch } from '@/lib/hooks/use-du-an';
|
||||
import { useResidentialProjectsFlag } from '@/lib/hooks/use-residential-projects-flag';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ProjectMap = dynamic(
|
||||
@@ -31,6 +33,7 @@ const PAGE_SIZE = 12;
|
||||
type ViewMode = 'grid' | 'list' | 'map';
|
||||
|
||||
export default function DuAnPage() {
|
||||
const flagEnabled = useResidentialProjectsFlag();
|
||||
const [filters, setFilters] = React.useState<SearchProjectsParams>({
|
||||
page: 1,
|
||||
limit: PAGE_SIZE,
|
||||
@@ -39,6 +42,10 @@ export default function DuAnPage() {
|
||||
|
||||
const { data, isLoading, isError } = useProjectsSearch(filters);
|
||||
|
||||
if (!flagEnabled) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const handleFilterChange = (newFilters: SearchProjectsParams) => {
|
||||
setFilters({ ...newFilters, limit: PAGE_SIZE });
|
||||
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', () => {
|
||||
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(/Tầng thấp/)).toBeInTheDocument();
|
||||
});
|
||||
@@ -82,7 +82,7 @@ describe('ValuationResults', () => {
|
||||
it('hides drivers section when empty', () => {
|
||||
const noDrivers = { ...mockResult, priceDrivers: [] };
|
||||
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