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