Compare commits

...

10 Commits

Author SHA1 Message Date
Ho Ngoc Hai
38b9def99a feat: implement project development module, transfer management features, and industrial AVM model integration 2026-04-18 20:34:35 +07:00
Ho Ngoc Hai
0f3b4d7b0d feat(messaging): R8.4 add missing Conversation/Message migration (TEC-2767)
Schema models cho Conversation + ConversationParticipant + Message đã
được thêm trong commit 3b5da2d nhưng chưa có migration tương ứng. Bổ
sung migration để DB ready cho in-app messaging (REST + WS /messaging).

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-18 15:05:46 +07:00
136 changed files with 13297 additions and 216 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -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`, {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.');
});
});
});

View File

@@ -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';

View File

@@ -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)}`;
}
}

View File

@@ -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));
}

View File

@@ -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 {}

View File

@@ -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',
);
});
});
});

View File

@@ -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
* ──────────────────────────────────────────── */

View File

@@ -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,
) {}
}

View File

@@ -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');
});
});
});

View File

@@ -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),
);
}
}

View File

@@ -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,
) {}
}

View File

@@ -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 };
}
}

View File

@@ -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,
) {}
}

View File

@@ -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 };
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,3 @@
export class GetProjectQuery {
constructor(public readonly slugOrId: string) {}
}

View File

@@ -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,
});
}
}

View File

@@ -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,
) {}
}

View File

@@ -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();
}
}

View File

@@ -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>>;
}

View 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';

View File

@@ -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;
}

View File

@@ -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,
),
);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View 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 {}

View File

@@ -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('&lt;script&gt;');
});
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' },
}),
);
});
});

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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(

View File

@@ -13,5 +13,6 @@ export class SearchPropertiesQuery {
public readonly sortBy?: string,
public readonly page?: number,
public readonly perPage?: number,
public readonly featured?: boolean,
) {}
}

View File

@@ -51,6 +51,7 @@ export class SearchController {
dto.sortBy,
dto.page,
dto.perPage,
dto.featured,
),
);
}

View File

@@ -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)

View File

@@ -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);
});
});

View File

@@ -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';

View File

@@ -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;
}
}

View File

@@ -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)

View File

@@ -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',
);
}
}
}

View File

@@ -0,0 +1,7 @@
export class GenerateTransferUploadUrlsCommand {
constructor(
public readonly sellerId: string,
public readonly listingId: string | null,
public readonly files: { fileName: string; mimeType: string }[],
) {}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,2 @@
export { ModerateTransferListingCommand } from './moderate-transfer-listing.command';
export { ModerateTransferListingHandler } from './moderate-transfer-listing.handler';

View File

@@ -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,
) {}
}

View File

@@ -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 };
}
}

View File

@@ -0,0 +1,2 @@
export { ListPendingTransfersQuery } from './list-pending-transfers.query';
export { ListPendingTransfersHandler } from './list-pending-transfers.handler';

View File

@@ -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,
});
}
}

View File

@@ -0,0 +1,6 @@
export class ListPendingTransfersQuery {
constructor(
public readonly page: number = 1,
public readonly limit: number = 20,
) {}
}

View File

@@ -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[];
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View 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 tạo báo cáo. Quá trình này
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 (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>
);
}

View 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 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 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>
);
}

View 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 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>
);
}

View File

@@ -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 />;
}

View File

@@ -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);

View 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();
});
});

View File

@@ -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' });

View File

@@ -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');
});
});

View 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');
});
});

View 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);
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 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 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>
);
}

View 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>
);
}

View 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>
);
}

View 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);
});
});

View 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;
}

View 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();
}

View 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,
})),
}));

View 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,
}),
},
),
);

View File

@@ -0,0 +1,24 @@
import { z } from 'zod';
/**
* Vietnamese phone number rule:
* - 911 digits, optional leading +84 or 0.
* We keep validation pragmatic: whitespace is stripped, then the remaining
* string must be 911 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