feat: implement project development module, transfer management features, and industrial AVM model integration

This commit is contained in:
Ho Ngoc Hai
2026-04-18 20:34:35 +07:00
parent 0f3b4d7b0d
commit 38b9def99a
66 changed files with 9051 additions and 17 deletions

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

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

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

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

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

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

File diff suppressed because one or more lines are too long