diff --git a/apps/api/src/modules/analytics/infrastructure/services/avm-retrain-cron.service.ts b/apps/api/src/modules/analytics/infrastructure/services/avm-retrain-cron.service.ts new file mode 100644 index 0000000..ccb7988 --- /dev/null +++ b/apps/api/src/modules/analytics/infrastructure/services/avm-retrain-cron.service.ts @@ -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 { + 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 { + const rows = await this.prisma.$queryRaw` + 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 { + 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 = { '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 { + const url = `${this.aiServiceUrl}/avm/v2/train`; + const headers: Record = { '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; + } +} + +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; +} diff --git a/apps/api/src/modules/listings/domain/__tests__/listing.entity.spec.ts b/apps/api/src/modules/listings/domain/__tests__/listing.entity.spec.ts index 8a7fdb2..9b74376 100644 --- a/apps/api/src/modules/listings/domain/__tests__/listing.entity.spec.ts +++ b/apps/api/src/modules/listings/domain/__tests__/listing.entity.spec.ts @@ -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', () => { diff --git a/apps/api/src/modules/payments/domain/events/bank-transfer-confirmed.event.ts b/apps/api/src/modules/payments/domain/events/bank-transfer-confirmed.event.ts new file mode 100644 index 0000000..acf96f2 --- /dev/null +++ b/apps/api/src/modules/payments/domain/events/bank-transfer-confirmed.event.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/payments/presentation/controllers/__tests__/admin-payments.controller.spec.ts b/apps/api/src/modules/payments/presentation/controllers/__tests__/admin-payments.controller.spec.ts new file mode 100644 index 0000000..901acf7 --- /dev/null +++ b/apps/api/src/modules/payments/presentation/controllers/__tests__/admin-payments.controller.spec.ts @@ -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 }; + + 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'); + }); + }); +}); diff --git a/apps/api/src/modules/payments/presentation/controllers/admin-payments.controller.ts b/apps/api/src/modules/payments/presentation/controllers/admin-payments.controller.ts new file mode 100644 index 0000000..498c188 --- /dev/null +++ b/apps/api/src/modules/payments/presentation/controllers/admin-payments.controller.ts @@ -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 { + return this.commandBus.execute( + new ConfirmBankTransferCommand(id, user.sub, dto.bankReference), + ); + } +} diff --git a/apps/api/src/modules/projects/application/commands/create-project/create-project.command.ts b/apps/api/src/modules/projects/application/commands/create-project/create-project.command.ts new file mode 100644 index 0000000..447e19e --- /dev/null +++ b/apps/api/src/modules/projects/application/commands/create-project/create-project.command.ts @@ -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 | null, + public readonly masterPlanUrl: string | null, + public readonly minPrice: bigint | null, + public readonly maxPrice: bigint | null, + public readonly pricePerM2Range: Record | null, + public readonly totalArea: number | null, + public readonly buildingCount: number | null, + public readonly floorCount: number | null, + public readonly unitTypes: Record | null, + public readonly tags: string[], + public readonly startDate: Date | null, + public readonly completionDate: Date | null, + ) {} +} diff --git a/apps/api/src/modules/projects/application/commands/create-project/create-project.handler.ts b/apps/api/src/modules/projects/application/commands/create-project/create-project.handler.ts new file mode 100644 index 0000000..d8adeee --- /dev/null +++ b/apps/api/src/modules/projects/application/commands/create-project/create-project.handler.ts @@ -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 { + 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 }; + } +} diff --git a/apps/api/src/modules/projects/application/commands/update-project/update-project.command.ts b/apps/api/src/modules/projects/application/commands/update-project/update-project.command.ts new file mode 100644 index 0000000..1ba0231 --- /dev/null +++ b/apps/api/src/modules/projects/application/commands/update-project/update-project.command.ts @@ -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 | null, + public readonly masterPlanUrl?: string | null, + public readonly minPrice?: bigint | null, + public readonly maxPrice?: bigint | null, + public readonly pricePerM2Range?: Record | null, + public readonly totalArea?: number | null, + public readonly buildingCount?: number | null, + public readonly floorCount?: number | null, + public readonly unitTypes?: Record | null, + public readonly media?: Record[] | null, + public readonly documents?: Record[] | null, + public readonly tags?: string[], + public readonly isVerified?: boolean, + public readonly startDate?: Date | null, + public readonly completionDate?: Date | null, + ) {} +} diff --git a/apps/api/src/modules/projects/application/commands/update-project/update-project.handler.ts b/apps/api/src/modules/projects/application/commands/update-project/update-project.handler.ts new file mode 100644 index 0000000..dbdaac4 --- /dev/null +++ b/apps/api/src/modules/projects/application/commands/update-project/update-project.handler.ts @@ -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 { + 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 }; + } +} diff --git a/apps/api/src/modules/projects/application/queries/get-project/get-project.handler.ts b/apps/api/src/modules/projects/application/queries/get-project/get-project.handler.ts new file mode 100644 index 0000000..37e8bac --- /dev/null +++ b/apps/api/src/modules/projects/application/queries/get-project/get-project.handler.ts @@ -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 { + constructor( + @Inject(PROJECT_REPOSITORY) + private readonly repo: IProjectRepository, + ) {} + + async execute(query: GetProjectQuery): Promise { + // Try slug first, then ID + const bySlug = await this.repo.findDetailBySlug(query.slugOrId); + if (bySlug) return bySlug; + return this.repo.findDetailById(query.slugOrId); + } +} diff --git a/apps/api/src/modules/projects/application/queries/get-project/get-project.query.ts b/apps/api/src/modules/projects/application/queries/get-project/get-project.query.ts new file mode 100644 index 0000000..f82e32e --- /dev/null +++ b/apps/api/src/modules/projects/application/queries/get-project/get-project.query.ts @@ -0,0 +1,3 @@ +export class GetProjectQuery { + constructor(public readonly slugOrId: string) {} +} diff --git a/apps/api/src/modules/projects/application/queries/list-projects/list-projects.handler.ts b/apps/api/src/modules/projects/application/queries/list-projects/list-projects.handler.ts new file mode 100644 index 0000000..c79773a --- /dev/null +++ b/apps/api/src/modules/projects/application/queries/list-projects/list-projects.handler.ts @@ -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 { + constructor( + @Inject(PROJECT_REPOSITORY) + private readonly repo: IProjectRepository, + ) {} + + async execute(query: ListProjectsQuery): Promise> { + 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, + }); + } +} diff --git a/apps/api/src/modules/projects/application/queries/list-projects/list-projects.query.ts b/apps/api/src/modules/projects/application/queries/list-projects/list-projects.query.ts new file mode 100644 index 0000000..4d98bf4 --- /dev/null +++ b/apps/api/src/modules/projects/application/queries/list-projects/list-projects.query.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/projects/domain/entities/project-development.entity.ts b/apps/api/src/modules/projects/domain/entities/project-development.entity.ts new file mode 100644 index 0000000..2805b33 --- /dev/null +++ b/apps/api/src/modules/projects/domain/entities/project-development.entity.ts @@ -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 | null; + masterPlanUrl: string | null; + latitude: number; + longitude: number; + address: string; + ward: string; + district: string; + city: string; + minPrice: bigint | null; + maxPrice: bigint | null; + pricePerM2Range: Record | null; + totalArea: number | null; + buildingCount: number | null; + floorCount: number | null; + unitTypes: Record | null; + media: Record[] | null; + documents: Record[] | null; + tags: string[]; + isVerified: boolean; +} + +export class ProjectDevelopmentEntity extends AggregateRoot { + 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 | 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 | null; + private _totalArea: number | null; + private _buildingCount: number | null; + private _floorCount: number | null; + private _unitTypes: Record | null; + private _media: Record[] | null; + private _documents: Record[] | 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): 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(); + } +} diff --git a/apps/api/src/modules/projects/domain/repositories/project-development.repository.ts b/apps/api/src/modules/projects/domain/repositories/project-development.repository.ts new file mode 100644 index 0000000..e9dc2c1 --- /dev/null +++ b/apps/api/src/modules/projects/domain/repositories/project-development.repository.ts @@ -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 { + 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 | null; + masterPlanUrl: string | null; + pricePerM2Range: Record | null; + buildingCount: number | null; + floorCount: number | null; + unitTypes: Record | null; + media: Record[] | null; + documents: Record[] | null; + updatedAt: Date; +} + +export interface IProjectRepository { + findById(id: string): Promise; + findBySlug(slug: string): Promise; + findDetailBySlug(slug: string): Promise; + findDetailById(id: string): Promise; + save(entity: ProjectDevelopmentEntity): Promise; + update(entity: ProjectDevelopmentEntity): Promise; + search(params: ProjectSearchParams): Promise>; +} diff --git a/apps/api/src/modules/projects/index.ts b/apps/api/src/modules/projects/index.ts new file mode 100644 index 0000000..1a4cd80 --- /dev/null +++ b/apps/api/src/modules/projects/index.ts @@ -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'; diff --git a/apps/api/src/modules/projects/infrastructure/repositories/prisma-project-development.repository.ts b/apps/api/src/modules/projects/infrastructure/repositories/prisma-project-development.repository.ts new file mode 100644 index 0000000..87467e3 --- /dev/null +++ b/apps/api/src/modules/projects/infrastructure/repositories/prisma-project-development.repository.ts @@ -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 { + const row = await this.prisma.$queryRaw` + 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 { + const row = await this.prisma.$queryRaw` + 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 { + const rows = await this.prisma.$queryRaw` + 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 { + const rows = await this.prisma.$queryRaw` + 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 { + 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 { + 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> { + 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( + `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 | 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 | null, + totalArea: row.totalArea, + buildingCount: row.buildingCount, + floorCount: row.floorCount, + unitTypes: row.unitTypes as Record | null, + media: row.media as Record[] | null, + documents: row.documents as Record[] | 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 | null, + masterPlanUrl: row.masterPlanUrl, + pricePerM2Range: row.pricePerM2Range as Record | null, + buildingCount: row.buildingCount, + floorCount: row.floorCount, + unitTypes: row.unitTypes as Record | null, + media: row.media as Record[] | null, + documents: row.documents as Record[] | 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; +} diff --git a/apps/api/src/modules/projects/presentation/controllers/projects.controller.ts b/apps/api/src/modules/projects/presentation/controllers/projects.controller.ts new file mode 100644 index 0000000..c991c4c --- /dev/null +++ b/apps/api/src/modules/projects/presentation/controllers/projects.controller.ts @@ -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, + ), + ); + } +} diff --git a/apps/api/src/modules/projects/presentation/dto/create-project.dto.ts b/apps/api/src/modules/projects/presentation/dto/create-project.dto.ts new file mode 100644 index 0000000..bb8786c --- /dev/null +++ b/apps/api/src/modules/projects/presentation/dto/create-project.dto.ts @@ -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; + + @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; + + @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; + + @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; +} diff --git a/apps/api/src/modules/projects/presentation/dto/search-projects.dto.ts b/apps/api/src/modules/projects/presentation/dto/search-projects.dto.ts new file mode 100644 index 0000000..8fb5cd3 --- /dev/null +++ b/apps/api/src/modules/projects/presentation/dto/search-projects.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/projects/presentation/dto/update-project.dto.ts b/apps/api/src/modules/projects/presentation/dto/update-project.dto.ts new file mode 100644 index 0000000..8c0e3f5 --- /dev/null +++ b/apps/api/src/modules/projects/presentation/dto/update-project.dto.ts @@ -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 | null; + @ApiPropertyOptional() @IsOptional() @IsString() masterPlanUrl?: string | null; + @ApiPropertyOptional() @IsOptional() @IsString() minPrice?: string | null; + @ApiPropertyOptional() @IsOptional() @IsString() maxPrice?: string | null; + @ApiPropertyOptional() @IsOptional() @IsObject() pricePerM2Range?: Record | 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 | null; + @ApiPropertyOptional() @IsOptional() @IsArray() media?: Record[] | null; + @ApiPropertyOptional() @IsOptional() @IsArray() documents?: Record[] | 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; +} diff --git a/apps/api/src/modules/projects/projects.module.ts b/apps/api/src/modules/projects/projects.module.ts new file mode 100644 index 0000000..bee7011 --- /dev/null +++ b/apps/api/src/modules/projects/projects.module.ts @@ -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 {} diff --git a/apps/api/src/modules/reports/infrastructure/__tests__/pdf-generator.service.spec.ts b/apps/api/src/modules/reports/infrastructure/__tests__/pdf-generator.service.spec.ts new file mode 100644 index 0000000..ae99a60 --- /dev/null +++ b/apps/api/src/modules/reports/infrastructure/__tests__/pdf-generator.service.spec.ts @@ -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 = {}): Record => ({ + 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(' { + 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: '', + }); + + await service.generatePdf('report-xss', content); + + const html = mockSetContent.mock.calls[0][0] as string; + expect(html).not.toContain('