feat(industrial): add IndustrialListing CRUD endpoints + Typesense indexing

Wire full DDD stack for IndustrialListing: domain entity, repository interface,
CQRS commands/queries with handlers, Prisma repository, Typesense sync on
create/update/delete, controller with 5 REST endpoints, and validated DTOs.
Register all providers in IndustrialModule.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 17:08:08 +07:00
parent 13bd76ac5d
commit 8f2d325d60
19 changed files with 1605 additions and 2 deletions

View File

@@ -0,0 +1,34 @@
import type { IndustrialLeaseType, IndustrialPropertyType } from '@prisma/client';
export class CreateIndustrialListingCommand {
constructor(
public readonly parkId: string,
public readonly sellerId: string,
public readonly agentId: string | null,
public readonly propertyType: IndustrialPropertyType,
public readonly leaseType: IndustrialLeaseType,
public readonly title: string,
public readonly description: string | null,
public readonly areaM2: number,
public readonly ceilingHeightM: number | null,
public readonly floorLoadTonM2: number | null,
public readonly columnSpacingM: number | null,
public readonly dockCount: number | null,
public readonly craneCapacityTon: number | null,
public readonly hasMezzanine: boolean,
public readonly hasOfficeArea: boolean,
public readonly officeAreaM2: number | null,
public readonly priceUsdM2: number | null,
public readonly pricingUnit: string | null,
public readonly totalLeasePrice: number | null,
public readonly managementFee: number | null,
public readonly depositMonths: number | null,
public readonly minLeaseYears: number | null,
public readonly maxLeaseYears: number | null,
public readonly leaseExpiry: Date | null,
public readonly availableFrom: Date | null,
public readonly powerCapacityKva: number | null,
public readonly waterSupplyM3Day: number | null,
public readonly media: Record<string, unknown>[] | null,
) {}
}

View File

@@ -0,0 +1,75 @@
import { Inject } from '@nestjs/common';
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
import { NotFoundException } from '@modules/shared';
import { IndustrialListingEntity } from '../../../domain/entities/industrial-listing.entity';
import {
INDUSTRIAL_LISTING_REPOSITORY,
type IIndustrialListingRepository,
} from '../../../domain/repositories/industrial-listing.repository';
import { type IIndustrialParkRepository, INDUSTRIAL_PARK_REPOSITORY } from '../../../domain/repositories/industrial-park.repository';
import { type TypesenseIndustrialService } from '../../../infrastructure/services/typesense-industrial.service';
import { CreateIndustrialListingCommand } from './create-industrial-listing.command';
@CommandHandler(CreateIndustrialListingCommand)
export class CreateIndustrialListingHandler implements ICommandHandler<CreateIndustrialListingCommand> {
constructor(
@Inject(INDUSTRIAL_LISTING_REPOSITORY)
private readonly repo: IIndustrialListingRepository,
@Inject(INDUSTRIAL_PARK_REPOSITORY)
private readonly parkRepo: IIndustrialParkRepository,
private readonly typesense: TypesenseIndustrialService,
) {}
async execute(cmd: CreateIndustrialListingCommand): Promise<{ id: string }> {
const park = await this.parkRepo.findById(cmd.parkId);
if (!park) {
throw new NotFoundException('Industrial park', cmd.parkId);
}
const now = new Date();
const entity = new IndustrialListingEntity(
createId(),
{
parkId: cmd.parkId,
agentId: cmd.agentId,
sellerId: cmd.sellerId,
propertyType: cmd.propertyType,
leaseType: cmd.leaseType,
status: 'DRAFT',
title: cmd.title,
description: cmd.description,
areaM2: cmd.areaM2,
ceilingHeightM: cmd.ceilingHeightM,
floorLoadTonM2: cmd.floorLoadTonM2,
columnSpacingM: cmd.columnSpacingM,
dockCount: cmd.dockCount,
craneCapacityTon: cmd.craneCapacityTon,
hasMezzanine: cmd.hasMezzanine,
hasOfficeArea: cmd.hasOfficeArea,
officeAreaM2: cmd.officeAreaM2,
priceUsdM2: cmd.priceUsdM2,
pricingUnit: cmd.pricingUnit,
totalLeasePrice: cmd.totalLeasePrice,
managementFee: cmd.managementFee,
depositMonths: cmd.depositMonths,
minLeaseYears: cmd.minLeaseYears,
maxLeaseYears: cmd.maxLeaseYears,
leaseExpiry: cmd.leaseExpiry,
availableFrom: cmd.availableFrom,
powerCapacityKva: cmd.powerCapacityKva,
waterSupplyM3Day: cmd.waterSupplyM3Day,
media: cmd.media,
viewCount: 0,
inquiryCount: 0,
publishedAt: null,
},
now,
now,
);
await this.repo.save(entity);
await this.typesense.indexListing(entity.id).catch(() => {});
return { id: entity.id };
}
}

View File

@@ -0,0 +1,5 @@
export class DeleteIndustrialListingCommand {
constructor(
public readonly id: string,
) {}
}

View File

@@ -0,0 +1,29 @@
import { Inject } from '@nestjs/common';
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
import { NotFoundException } from '@modules/shared';
import {
INDUSTRIAL_LISTING_REPOSITORY,
type IIndustrialListingRepository,
} from '../../../domain/repositories/industrial-listing.repository';
import { type TypesenseIndustrialService } from '../../../infrastructure/services/typesense-industrial.service';
import { DeleteIndustrialListingCommand } from './delete-industrial-listing.command';
@CommandHandler(DeleteIndustrialListingCommand)
export class DeleteIndustrialListingHandler implements ICommandHandler<DeleteIndustrialListingCommand> {
constructor(
@Inject(INDUSTRIAL_LISTING_REPOSITORY)
private readonly repo: IIndustrialListingRepository,
private readonly typesense: TypesenseIndustrialService,
) {}
async execute(cmd: DeleteIndustrialListingCommand): Promise<void> {
const entity = await this.repo.findById(cmd.id);
if (!entity) {
throw new NotFoundException('Industrial listing', cmd.id);
}
entity.softDelete();
await this.repo.update(entity);
await this.typesense.deleteListing(cmd.id).catch(() => {});
}
}

View File

@@ -0,0 +1,33 @@
import type { IndustrialLeaseType, IndustrialListingStatus, IndustrialPropertyType } from '@prisma/client';
export class UpdateIndustrialListingCommand {
constructor(
public readonly id: string,
public readonly propertyType?: IndustrialPropertyType,
public readonly leaseType?: IndustrialLeaseType,
public readonly status?: IndustrialListingStatus,
public readonly title?: string,
public readonly description?: string | null,
public readonly areaM2?: number,
public readonly ceilingHeightM?: number | null,
public readonly floorLoadTonM2?: number | null,
public readonly columnSpacingM?: number | null,
public readonly dockCount?: number | null,
public readonly craneCapacityTon?: number | null,
public readonly hasMezzanine?: boolean,
public readonly hasOfficeArea?: boolean,
public readonly officeAreaM2?: number | null,
public readonly priceUsdM2?: number | null,
public readonly pricingUnit?: string | null,
public readonly totalLeasePrice?: number | null,
public readonly managementFee?: number | null,
public readonly depositMonths?: number | null,
public readonly minLeaseYears?: number | null,
public readonly maxLeaseYears?: number | null,
public readonly leaseExpiry?: Date | null,
public readonly availableFrom?: Date | null,
public readonly powerCapacityKva?: number | null,
public readonly waterSupplyM3Day?: number | null,
public readonly media?: Record<string, unknown>[] | null,
) {}
}

View File

@@ -0,0 +1,57 @@
import { Inject } from '@nestjs/common';
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
import { NotFoundException } from '@modules/shared';
import {
INDUSTRIAL_LISTING_REPOSITORY,
type IIndustrialListingRepository,
} from '../../../domain/repositories/industrial-listing.repository';
import { type TypesenseIndustrialService } from '../../../infrastructure/services/typesense-industrial.service';
import { UpdateIndustrialListingCommand } from './update-industrial-listing.command';
@CommandHandler(UpdateIndustrialListingCommand)
export class UpdateIndustrialListingHandler implements ICommandHandler<UpdateIndustrialListingCommand> {
constructor(
@Inject(INDUSTRIAL_LISTING_REPOSITORY)
private readonly repo: IIndustrialListingRepository,
private readonly typesense: TypesenseIndustrialService,
) {}
async execute(cmd: UpdateIndustrialListingCommand): Promise<void> {
const entity = await this.repo.findById(cmd.id);
if (!entity) {
throw new NotFoundException('Industrial listing', cmd.id);
}
entity.updateDetails({
propertyType: cmd.propertyType,
leaseType: cmd.leaseType,
status: cmd.status,
title: cmd.title,
description: cmd.description,
areaM2: cmd.areaM2,
ceilingHeightM: cmd.ceilingHeightM,
floorLoadTonM2: cmd.floorLoadTonM2,
columnSpacingM: cmd.columnSpacingM,
dockCount: cmd.dockCount,
craneCapacityTon: cmd.craneCapacityTon,
hasMezzanine: cmd.hasMezzanine,
hasOfficeArea: cmd.hasOfficeArea,
officeAreaM2: cmd.officeAreaM2,
priceUsdM2: cmd.priceUsdM2,
pricingUnit: cmd.pricingUnit,
totalLeasePrice: cmd.totalLeasePrice,
managementFee: cmd.managementFee,
depositMonths: cmd.depositMonths,
minLeaseYears: cmd.minLeaseYears,
maxLeaseYears: cmd.maxLeaseYears,
leaseExpiry: cmd.leaseExpiry,
availableFrom: cmd.availableFrom,
powerCapacityKva: cmd.powerCapacityKva,
waterSupplyM3Day: cmd.waterSupplyM3Day,
media: cmd.media,
});
await this.repo.update(entity);
await this.typesense.indexListing(entity.id).catch(() => {});
}
}

View File

@@ -0,0 +1,20 @@
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import {
INDUSTRIAL_LISTING_REPOSITORY,
type IIndustrialListingRepository,
type IndustrialListingDetailData,
} from '../../../domain/repositories/industrial-listing.repository';
import { GetIndustrialListingQuery } from './get-industrial-listing.query';
@QueryHandler(GetIndustrialListingQuery)
export class GetIndustrialListingHandler implements IQueryHandler<GetIndustrialListingQuery> {
constructor(
@Inject(INDUSTRIAL_LISTING_REPOSITORY)
private readonly repo: IIndustrialListingRepository,
) {}
async execute(query: GetIndustrialListingQuery): Promise<IndustrialListingDetailData | null> {
return this.repo.findDetailById(query.id);
}
}

View File

@@ -0,0 +1,5 @@
export class GetIndustrialListingQuery {
constructor(
public readonly id: string,
) {}
}

View File

@@ -0,0 +1,33 @@
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import {
INDUSTRIAL_LISTING_REPOSITORY,
type IIndustrialListingRepository,
type IndustrialListingListItem,
type PaginatedResult,
} from '../../../domain/repositories/industrial-listing.repository';
import { ListIndustrialListingsQuery } from './list-industrial-listings.query';
@QueryHandler(ListIndustrialListingsQuery)
export class ListIndustrialListingsHandler implements IQueryHandler<ListIndustrialListingsQuery> {
constructor(
@Inject(INDUSTRIAL_LISTING_REPOSITORY)
private readonly repo: IIndustrialListingRepository,
) {}
async execute(query: ListIndustrialListingsQuery): Promise<PaginatedResult<IndustrialListingListItem>> {
return this.repo.search({
parkId: query.parkId,
propertyType: query.propertyType,
leaseType: query.leaseType,
status: query.status,
minAreaM2: query.minAreaM2,
maxAreaM2: query.maxAreaM2,
minPriceUsdM2: query.minPriceUsdM2,
maxPriceUsdM2: query.maxPriceUsdM2,
query: query.query,
page: query.page,
limit: query.limit,
});
}
}

View File

@@ -0,0 +1,17 @@
import type { IndustrialLeaseType, IndustrialListingStatus, IndustrialPropertyType } from '@prisma/client';
export class ListIndustrialListingsQuery {
constructor(
public readonly parkId?: string,
public readonly propertyType?: IndustrialPropertyType,
public readonly leaseType?: IndustrialLeaseType,
public readonly status?: IndustrialListingStatus,
public readonly minAreaM2?: number,
public readonly maxAreaM2?: number,
public readonly minPriceUsdM2?: number,
public readonly maxPriceUsdM2?: number,
public readonly query?: string,
public readonly page: number = 1,
public readonly limit: number = 20,
) {}
}

View File

@@ -0,0 +1,177 @@
import { type IndustrialLeaseType, type IndustrialListingStatus, type IndustrialPropertyType } from '@prisma/client';
import { AggregateRoot } from '@modules/shared';
export interface IndustrialListingProps {
parkId: string;
agentId: string | null;
sellerId: string;
propertyType: IndustrialPropertyType;
leaseType: IndustrialLeaseType;
status: IndustrialListingStatus;
title: string;
description: string | null;
areaM2: number;
ceilingHeightM: number | null;
floorLoadTonM2: number | null;
columnSpacingM: number | null;
dockCount: number | null;
craneCapacityTon: number | null;
hasMezzanine: boolean;
hasOfficeArea: boolean;
officeAreaM2: number | null;
priceUsdM2: number | null;
pricingUnit: string | null;
totalLeasePrice: number | null;
managementFee: number | null;
depositMonths: number | null;
minLeaseYears: number | null;
maxLeaseYears: number | null;
leaseExpiry: Date | null;
availableFrom: Date | null;
powerCapacityKva: number | null;
waterSupplyM3Day: number | null;
media: Record<string, unknown>[] | null;
viewCount: number;
inquiryCount: number;
publishedAt: Date | null;
}
export class IndustrialListingEntity extends AggregateRoot<string> {
private _parkId: string;
private _agentId: string | null;
private _sellerId: string;
private _propertyType: IndustrialPropertyType;
private _leaseType: IndustrialLeaseType;
private _status: IndustrialListingStatus;
private _title: string;
private _description: string | null;
private _areaM2: number;
private _ceilingHeightM: number | null;
private _floorLoadTonM2: number | null;
private _columnSpacingM: number | null;
private _dockCount: number | null;
private _craneCapacityTon: number | null;
private _hasMezzanine: boolean;
private _hasOfficeArea: boolean;
private _officeAreaM2: number | null;
private _priceUsdM2: number | null;
private _pricingUnit: string | null;
private _totalLeasePrice: number | null;
private _managementFee: number | null;
private _depositMonths: number | null;
private _minLeaseYears: number | null;
private _maxLeaseYears: number | null;
private _leaseExpiry: Date | null;
private _availableFrom: Date | null;
private _powerCapacityKva: number | null;
private _waterSupplyM3Day: number | null;
private _media: Record<string, unknown>[] | null;
private _viewCount: number;
private _inquiryCount: number;
private _publishedAt: Date | null;
constructor(id: string, props: IndustrialListingProps, createdAt: Date, updatedAt: Date) {
super(id, createdAt, updatedAt);
this._parkId = props.parkId;
this._agentId = props.agentId;
this._sellerId = props.sellerId;
this._propertyType = props.propertyType;
this._leaseType = props.leaseType;
this._status = props.status;
this._title = props.title;
this._description = props.description;
this._areaM2 = props.areaM2;
this._ceilingHeightM = props.ceilingHeightM;
this._floorLoadTonM2 = props.floorLoadTonM2;
this._columnSpacingM = props.columnSpacingM;
this._dockCount = props.dockCount;
this._craneCapacityTon = props.craneCapacityTon;
this._hasMezzanine = props.hasMezzanine;
this._hasOfficeArea = props.hasOfficeArea;
this._officeAreaM2 = props.officeAreaM2;
this._priceUsdM2 = props.priceUsdM2;
this._pricingUnit = props.pricingUnit;
this._totalLeasePrice = props.totalLeasePrice;
this._managementFee = props.managementFee;
this._depositMonths = props.depositMonths;
this._minLeaseYears = props.minLeaseYears;
this._maxLeaseYears = props.maxLeaseYears;
this._leaseExpiry = props.leaseExpiry;
this._availableFrom = props.availableFrom;
this._powerCapacityKva = props.powerCapacityKva;
this._waterSupplyM3Day = props.waterSupplyM3Day;
this._media = props.media;
this._viewCount = props.viewCount;
this._inquiryCount = props.inquiryCount;
this._publishedAt = props.publishedAt;
}
get parkId() { return this._parkId; }
get agentId() { return this._agentId; }
get sellerId() { return this._sellerId; }
get propertyType() { return this._propertyType; }
get leaseType() { return this._leaseType; }
get status() { return this._status; }
get title() { return this._title; }
get description() { return this._description; }
get areaM2() { return this._areaM2; }
get ceilingHeightM() { return this._ceilingHeightM; }
get floorLoadTonM2() { return this._floorLoadTonM2; }
get columnSpacingM() { return this._columnSpacingM; }
get dockCount() { return this._dockCount; }
get craneCapacityTon() { return this._craneCapacityTon; }
get hasMezzanine() { return this._hasMezzanine; }
get hasOfficeArea() { return this._hasOfficeArea; }
get officeAreaM2() { return this._officeAreaM2; }
get priceUsdM2() { return this._priceUsdM2; }
get pricingUnit() { return this._pricingUnit; }
get totalLeasePrice() { return this._totalLeasePrice; }
get managementFee() { return this._managementFee; }
get depositMonths() { return this._depositMonths; }
get minLeaseYears() { return this._minLeaseYears; }
get maxLeaseYears() { return this._maxLeaseYears; }
get leaseExpiry() { return this._leaseExpiry; }
get availableFrom() { return this._availableFrom; }
get powerCapacityKva() { return this._powerCapacityKva; }
get waterSupplyM3Day() { return this._waterSupplyM3Day; }
get media() { return this._media; }
get viewCount() { return this._viewCount; }
get inquiryCount() { return this._inquiryCount; }
get publishedAt() { return this._publishedAt; }
updateDetails(props: Partial<Omit<IndustrialListingProps, 'parkId' | 'sellerId'>>): void {
if (props.agentId !== undefined) this._agentId = props.agentId;
if (props.propertyType !== undefined) this._propertyType = props.propertyType;
if (props.leaseType !== undefined) this._leaseType = props.leaseType;
if (props.status !== undefined) this._status = props.status;
if (props.title !== undefined) this._title = props.title;
if (props.description !== undefined) this._description = props.description;
if (props.areaM2 !== undefined) this._areaM2 = props.areaM2;
if (props.ceilingHeightM !== undefined) this._ceilingHeightM = props.ceilingHeightM;
if (props.floorLoadTonM2 !== undefined) this._floorLoadTonM2 = props.floorLoadTonM2;
if (props.columnSpacingM !== undefined) this._columnSpacingM = props.columnSpacingM;
if (props.dockCount !== undefined) this._dockCount = props.dockCount;
if (props.craneCapacityTon !== undefined) this._craneCapacityTon = props.craneCapacityTon;
if (props.hasMezzanine !== undefined) this._hasMezzanine = props.hasMezzanine;
if (props.hasOfficeArea !== undefined) this._hasOfficeArea = props.hasOfficeArea;
if (props.officeAreaM2 !== undefined) this._officeAreaM2 = props.officeAreaM2;
if (props.priceUsdM2 !== undefined) this._priceUsdM2 = props.priceUsdM2;
if (props.pricingUnit !== undefined) this._pricingUnit = props.pricingUnit;
if (props.totalLeasePrice !== undefined) this._totalLeasePrice = props.totalLeasePrice;
if (props.managementFee !== undefined) this._managementFee = props.managementFee;
if (props.depositMonths !== undefined) this._depositMonths = props.depositMonths;
if (props.minLeaseYears !== undefined) this._minLeaseYears = props.minLeaseYears;
if (props.maxLeaseYears !== undefined) this._maxLeaseYears = props.maxLeaseYears;
if (props.leaseExpiry !== undefined) this._leaseExpiry = props.leaseExpiry;
if (props.availableFrom !== undefined) this._availableFrom = props.availableFrom;
if (props.powerCapacityKva !== undefined) this._powerCapacityKva = props.powerCapacityKva;
if (props.waterSupplyM3Day !== undefined) this._waterSupplyM3Day = props.waterSupplyM3Day;
if (props.media !== undefined) this._media = props.media;
this.updatedAt = new Date();
}
softDelete(): void {
this._status = 'EXPIRED' as IndustrialListingStatus;
this.updatedAt = new Date();
}
}

View File

@@ -0,0 +1,92 @@
import type { IndustrialLeaseType, IndustrialListingStatus, IndustrialPropertyType } from '@prisma/client';
import type { IndustrialListingEntity } from '../entities/industrial-listing.entity';
export const INDUSTRIAL_LISTING_REPOSITORY = Symbol('INDUSTRIAL_LISTING_REPOSITORY');
export interface IndustrialListingSearchParams {
parkId?: string;
propertyType?: IndustrialPropertyType;
leaseType?: IndustrialLeaseType;
status?: IndustrialListingStatus;
minAreaM2?: number;
maxAreaM2?: number;
minPriceUsdM2?: number;
maxPriceUsdM2?: number;
query?: string;
page?: number;
limit?: number;
}
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface IndustrialListingListItem {
id: string;
parkId: string;
parkName: string;
propertyType: IndustrialPropertyType;
leaseType: IndustrialLeaseType;
status: IndustrialListingStatus;
title: string;
areaM2: number;
priceUsdM2: number | null;
pricingUnit: string | null;
ceilingHeightM: number | null;
hasMezzanine: boolean;
hasOfficeArea: boolean;
publishedAt: Date | null;
createdAt: Date;
}
export interface IndustrialListingDetailData {
id: string;
parkId: string;
parkName: string;
parkSlug: string;
agentId: string | null;
sellerId: string;
propertyType: IndustrialPropertyType;
leaseType: IndustrialLeaseType;
status: IndustrialListingStatus;
title: string;
description: string | null;
areaM2: number;
ceilingHeightM: number | null;
floorLoadTonM2: number | null;
columnSpacingM: number | null;
dockCount: number | null;
craneCapacityTon: number | null;
hasMezzanine: boolean;
hasOfficeArea: boolean;
officeAreaM2: number | null;
priceUsdM2: number | null;
pricingUnit: string | null;
totalLeasePrice: number | null;
managementFee: number | null;
depositMonths: number | null;
minLeaseYears: number | null;
maxLeaseYears: number | null;
leaseExpiry: Date | null;
availableFrom: Date | null;
powerCapacityKva: number | null;
waterSupplyM3Day: number | null;
media: Record<string, unknown>[] | null;
viewCount: number;
inquiryCount: number;
publishedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface IIndustrialListingRepository {
findById(id: string): Promise<IndustrialListingEntity | null>;
findDetailById(id: string): Promise<IndustrialListingDetailData | null>;
save(entity: IndustrialListingEntity): Promise<void>;
update(entity: IndustrialListingEntity): Promise<void>;
search(params: IndustrialListingSearchParams): Promise<PaginatedResult<IndustrialListingListItem>>;
}

View File

@@ -1,21 +1,32 @@
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { SearchModule } from '@modules/search';
import { CreateIndustrialListingHandler } from './application/commands/create-industrial-listing/create-industrial-listing.handler';
import { CreateIndustrialParkHandler } from './application/commands/create-industrial-park/create-industrial-park.handler';
import { DeleteIndustrialListingHandler } from './application/commands/delete-industrial-listing/delete-industrial-listing.handler';
import { UpdateIndustrialListingHandler } from './application/commands/update-industrial-listing/update-industrial-listing.handler';
import { UpdateIndustrialParkHandler } from './application/commands/update-industrial-park/update-industrial-park.handler';
import { CompareIndustrialParksHandler } from './application/queries/compare-industrial-parks/compare-industrial-parks.handler';
import { GetIndustrialListingHandler } from './application/queries/get-industrial-listing/get-industrial-listing.handler';
import { GetIndustrialParkHandler } from './application/queries/get-industrial-park/get-industrial-park.handler';
import { IndustrialMarketHandler } from './application/queries/industrial-market/industrial-market.handler';
import { IndustrialParkStatsHandler } from './application/queries/industrial-park-stats/industrial-park-stats.handler';
import { ListIndustrialListingsHandler } from './application/queries/list-industrial-listings/list-industrial-listings.handler';
import { ListIndustrialParksHandler } from './application/queries/list-industrial-parks/list-industrial-parks.handler';
import { INDUSTRIAL_LISTING_REPOSITORY } from './domain/repositories/industrial-listing.repository';
import { INDUSTRIAL_PARK_REPOSITORY } from './domain/repositories/industrial-park.repository';
import { PrismaIndustrialListingRepository } from './infrastructure/repositories/prisma-industrial-listing.repository';
import { PrismaIndustrialParkRepository } from './infrastructure/repositories/prisma-industrial-park.repository';
import { TypesenseIndustrialService } from './infrastructure/services/typesense-industrial.service';
import { IndustrialListingsController } from './presentation/controllers/industrial-listings.controller';
import { IndustrialParksController } from './presentation/controllers/industrial-parks.controller';
const CommandHandlers = [
CreateIndustrialParkHandler,
UpdateIndustrialParkHandler,
CreateIndustrialListingHandler,
UpdateIndustrialListingHandler,
DeleteIndustrialListingHandler,
];
const QueryHandlers = [
@@ -24,17 +35,20 @@ const QueryHandlers = [
CompareIndustrialParksHandler,
IndustrialParkStatsHandler,
IndustrialMarketHandler,
GetIndustrialListingHandler,
ListIndustrialListingsHandler,
];
@Module({
imports: [CqrsModule, SearchModule],
controllers: [IndustrialParksController],
controllers: [IndustrialParksController, IndustrialListingsController],
providers: [
{ provide: INDUSTRIAL_PARK_REPOSITORY, useClass: PrismaIndustrialParkRepository },
{ provide: INDUSTRIAL_LISTING_REPOSITORY, useClass: PrismaIndustrialListingRepository },
TypesenseIndustrialService,
...CommandHandlers,
...QueryHandlers,
],
exports: [INDUSTRIAL_PARK_REPOSITORY, TypesenseIndustrialService],
exports: [INDUSTRIAL_PARK_REPOSITORY, INDUSTRIAL_LISTING_REPOSITORY, TypesenseIndustrialService],
})
export class IndustrialModule {}

View File

@@ -0,0 +1,342 @@
import { Injectable } from '@nestjs/common';
import type { IndustrialLeaseType, IndustrialListingStatus, IndustrialPropertyType, Prisma } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { IndustrialListingEntity } from '../../domain/entities/industrial-listing.entity';
import type {
IIndustrialListingRepository,
IndustrialListingSearchParams,
PaginatedResult,
IndustrialListingListItem,
IndustrialListingDetailData,
} from '../../domain/repositories/industrial-listing.repository';
@Injectable()
export class PrismaIndustrialListingRepository implements IIndustrialListingRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<IndustrialListingEntity | null> {
const rows = await this.prisma.$queryRaw<RawListing[]>`
SELECT * FROM "IndustrialListing" WHERE id = ${id} LIMIT 1
`;
return rows[0] ? this.toDomain(rows[0]) : null;
}
async findDetailById(id: string): Promise<IndustrialListingDetailData | null> {
const rows = await this.prisma.$queryRaw<RawListingDetail[]>`
SELECT l.*, p.name as "parkName", p.slug as "parkSlug"
FROM "IndustrialListing" l
JOIN "IndustrialPark" p ON p.id = l."parkId"
WHERE l.id = ${id}
LIMIT 1
`;
return rows[0] ? this.toDetail(rows[0]) : null;
}
async save(entity: IndustrialListingEntity): Promise<void> {
await this.prisma.$executeRaw`
INSERT INTO "IndustrialListing" (
id, "parkId", "agentId", "sellerId", "propertyType", "leaseType", status,
title, description, "areaM2", "ceilingHeightM", "floorLoadTonM2",
"columnSpacingM", "dockCount", "craneCapacityTon", "hasMezzanine",
"hasOfficeArea", "officeAreaM2", "priceUsdM2", "pricingUnit",
"totalLeasePrice", "managementFee", "depositMonths", "minLeaseYears",
"maxLeaseYears", "leaseExpiry", "availableFrom", "powerCapacityKva",
"waterSupplyM3Day", media, "viewCount", "inquiryCount",
"publishedAt", "createdAt", "updatedAt"
) VALUES (
${entity.id}, ${entity.parkId}, ${entity.agentId}, ${entity.sellerId},
${entity.propertyType}::"IndustrialPropertyType",
${entity.leaseType}::"IndustrialLeaseType",
${entity.status}::"IndustrialListingStatus",
${entity.title}, ${entity.description}, ${entity.areaM2},
${entity.ceilingHeightM}, ${entity.floorLoadTonM2},
${entity.columnSpacingM}, ${entity.dockCount}, ${entity.craneCapacityTon},
${entity.hasMezzanine}, ${entity.hasOfficeArea}, ${entity.officeAreaM2},
${entity.priceUsdM2}, ${entity.pricingUnit}, ${entity.totalLeasePrice},
${entity.managementFee}, ${entity.depositMonths}, ${entity.minLeaseYears},
${entity.maxLeaseYears}, ${entity.leaseExpiry}, ${entity.availableFrom},
${entity.powerCapacityKva}, ${entity.waterSupplyM3Day},
${entity.media ? JSON.stringify(entity.media) : null}::jsonb,
${entity.viewCount}, ${entity.inquiryCount},
${entity.publishedAt}, ${entity.createdAt}, ${entity.updatedAt}
)
`;
}
async update(entity: IndustrialListingEntity): Promise<void> {
await this.prisma.$executeRaw`
UPDATE "IndustrialListing" SET
"agentId" = ${entity.agentId},
"propertyType" = ${entity.propertyType}::"IndustrialPropertyType",
"leaseType" = ${entity.leaseType}::"IndustrialLeaseType",
status = ${entity.status}::"IndustrialListingStatus",
title = ${entity.title},
description = ${entity.description},
"areaM2" = ${entity.areaM2},
"ceilingHeightM" = ${entity.ceilingHeightM},
"floorLoadTonM2" = ${entity.floorLoadTonM2},
"columnSpacingM" = ${entity.columnSpacingM},
"dockCount" = ${entity.dockCount},
"craneCapacityTon" = ${entity.craneCapacityTon},
"hasMezzanine" = ${entity.hasMezzanine},
"hasOfficeArea" = ${entity.hasOfficeArea},
"officeAreaM2" = ${entity.officeAreaM2},
"priceUsdM2" = ${entity.priceUsdM2},
"pricingUnit" = ${entity.pricingUnit},
"totalLeasePrice" = ${entity.totalLeasePrice},
"managementFee" = ${entity.managementFee},
"depositMonths" = ${entity.depositMonths},
"minLeaseYears" = ${entity.minLeaseYears},
"maxLeaseYears" = ${entity.maxLeaseYears},
"leaseExpiry" = ${entity.leaseExpiry},
"availableFrom" = ${entity.availableFrom},
"powerCapacityKva" = ${entity.powerCapacityKva},
"waterSupplyM3Day" = ${entity.waterSupplyM3Day},
media = ${entity.media ? JSON.stringify(entity.media) : null}::jsonb,
"updatedAt" = ${entity.updatedAt}
WHERE id = ${entity.id}
`;
}
async search(params: IndustrialListingSearchParams): Promise<PaginatedResult<IndustrialListingListItem>> {
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.parkId) {
conditions.push(`l."parkId" = $${paramIndex++}`);
values.push(params.parkId);
}
if (params.propertyType) {
conditions.push(`l."propertyType" = $${paramIndex++}::"IndustrialPropertyType"`);
values.push(params.propertyType);
}
if (params.leaseType) {
conditions.push(`l."leaseType" = $${paramIndex++}::"IndustrialLeaseType"`);
values.push(params.leaseType);
}
if (params.status) {
conditions.push(`l.status = $${paramIndex++}::"IndustrialListingStatus"`);
values.push(params.status);
}
if (params.minAreaM2 != null) {
conditions.push(`l."areaM2" >= $${paramIndex++}`);
values.push(params.minAreaM2);
}
if (params.maxAreaM2 != null) {
conditions.push(`l."areaM2" <= $${paramIndex++}`);
values.push(params.maxAreaM2);
}
if (params.minPriceUsdM2 != null) {
conditions.push(`l."priceUsdM2" >= $${paramIndex++}`);
values.push(params.minPriceUsdM2);
}
if (params.maxPriceUsdM2 != null) {
conditions.push(`l."priceUsdM2" <= $${paramIndex++}`);
values.push(params.maxPriceUsdM2);
}
if (params.query) {
conditions.push(`(l.title ILIKE $${paramIndex} OR l.description 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 "IndustrialListing" l WHERE ${where}`,
...values,
);
const total = Number(countResult[0].count);
const rows = await this.prisma.$queryRawUnsafe<RawListingListItem[]>(
`SELECT l.id, l."parkId", p.name as "parkName", l."propertyType"::text,
l."leaseType"::text, l.status::text, l.title, l."areaM2",
l."priceUsdM2", l."pricingUnit", l."ceilingHeightM",
l."hasMezzanine", l."hasOfficeArea", l."publishedAt", l."createdAt"
FROM "IndustrialListing" l
JOIN "IndustrialPark" p ON p.id = l."parkId"
WHERE ${where}
ORDER BY l."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: RawListing): IndustrialListingEntity {
return new IndustrialListingEntity(
row.id,
{
parkId: row.parkId,
agentId: row.agentId,
sellerId: row.sellerId,
propertyType: row.propertyType,
leaseType: row.leaseType,
status: row.status,
title: row.title,
description: row.description,
areaM2: row.areaM2,
ceilingHeightM: row.ceilingHeightM,
floorLoadTonM2: row.floorLoadTonM2,
columnSpacingM: row.columnSpacingM,
dockCount: row.dockCount,
craneCapacityTon: row.craneCapacityTon,
hasMezzanine: row.hasMezzanine,
hasOfficeArea: row.hasOfficeArea,
officeAreaM2: row.officeAreaM2,
priceUsdM2: row.priceUsdM2,
pricingUnit: row.pricingUnit,
totalLeasePrice: row.totalLeasePrice,
managementFee: row.managementFee,
depositMonths: row.depositMonths,
minLeaseYears: row.minLeaseYears,
maxLeaseYears: row.maxLeaseYears,
leaseExpiry: row.leaseExpiry,
availableFrom: row.availableFrom,
powerCapacityKva: row.powerCapacityKva,
waterSupplyM3Day: row.waterSupplyM3Day,
media: row.media as Record<string, unknown>[] | null,
viewCount: row.viewCount,
inquiryCount: row.inquiryCount,
publishedAt: row.publishedAt,
},
row.createdAt,
row.updatedAt,
);
}
private toListItem(row: RawListingListItem): IndustrialListingListItem {
return {
id: row.id,
parkId: row.parkId,
parkName: row.parkName,
propertyType: row.propertyType as IndustrialPropertyType,
leaseType: row.leaseType as IndustrialLeaseType,
status: row.status as IndustrialListingStatus,
title: row.title,
areaM2: row.areaM2,
priceUsdM2: row.priceUsdM2,
pricingUnit: row.pricingUnit,
ceilingHeightM: row.ceilingHeightM,
hasMezzanine: row.hasMezzanine,
hasOfficeArea: row.hasOfficeArea,
publishedAt: row.publishedAt,
createdAt: row.createdAt,
};
}
private toDetail(row: RawListingDetail): IndustrialListingDetailData {
return {
id: row.id,
parkId: row.parkId,
parkName: row.parkName,
parkSlug: row.parkSlug,
agentId: row.agentId,
sellerId: row.sellerId,
propertyType: row.propertyType,
leaseType: row.leaseType,
status: row.status,
title: row.title,
description: row.description,
areaM2: row.areaM2,
ceilingHeightM: row.ceilingHeightM,
floorLoadTonM2: row.floorLoadTonM2,
columnSpacingM: row.columnSpacingM,
dockCount: row.dockCount,
craneCapacityTon: row.craneCapacityTon,
hasMezzanine: row.hasMezzanine,
hasOfficeArea: row.hasOfficeArea,
officeAreaM2: row.officeAreaM2,
priceUsdM2: row.priceUsdM2,
pricingUnit: row.pricingUnit,
totalLeasePrice: row.totalLeasePrice,
managementFee: row.managementFee,
depositMonths: row.depositMonths,
minLeaseYears: row.minLeaseYears,
maxLeaseYears: row.maxLeaseYears,
leaseExpiry: row.leaseExpiry,
availableFrom: row.availableFrom,
powerCapacityKva: row.powerCapacityKva,
waterSupplyM3Day: row.waterSupplyM3Day,
media: row.media as Record<string, unknown>[] | null,
viewCount: row.viewCount,
inquiryCount: row.inquiryCount,
publishedAt: row.publishedAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
}
interface RawListing {
id: string;
parkId: string;
agentId: string | null;
sellerId: string;
propertyType: IndustrialPropertyType;
leaseType: IndustrialLeaseType;
status: IndustrialListingStatus;
title: string;
description: string | null;
areaM2: number;
ceilingHeightM: number | null;
floorLoadTonM2: number | null;
columnSpacingM: number | null;
dockCount: number | null;
craneCapacityTon: number | null;
hasMezzanine: boolean;
hasOfficeArea: boolean;
officeAreaM2: number | null;
priceUsdM2: number | null;
pricingUnit: string | null;
totalLeasePrice: number | null;
managementFee: number | null;
depositMonths: number | null;
minLeaseYears: number | null;
maxLeaseYears: number | null;
leaseExpiry: Date | null;
availableFrom: Date | null;
powerCapacityKva: number | null;
waterSupplyM3Day: number | null;
media: Prisma.JsonValue;
viewCount: number;
inquiryCount: number;
publishedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
interface RawListingListItem {
id: string;
parkId: string;
parkName: string;
propertyType: string;
leaseType: string;
status: string;
title: string;
areaM2: number;
priceUsdM2: number | null;
pricingUnit: string | null;
ceilingHeightM: number | null;
hasMezzanine: boolean;
hasOfficeArea: boolean;
publishedAt: Date | null;
createdAt: Date;
}
interface RawListingDetail extends RawListing {
parkName: string;
parkSlug: string;
}

View File

@@ -88,6 +88,28 @@ interface RawIndustrialPark {
createdAt: Date;
}
interface RawIndustrialListing {
id: string;
title: string;
description: string | null;
parkId: string;
parkName: string;
propertyType: string;
leaseType: string;
province: string;
region: string;
areaM2: number;
priceUsdM2: number | null;
ceilingHeightM: number | null;
floorLoadTonM2: number | null;
targetIndustries: string[] | null;
lat: number;
lng: number;
occupancyRate: number;
status: string;
publishedAt: Date | null;
}
@Injectable()
export class TypesenseIndustrialService implements OnModuleInit {
private client: TypesenseClient | null = null;
@@ -103,6 +125,7 @@ export class TypesenseIndustrialService implements OnModuleInit {
this.client = this.typesenseClient.getClient();
await this.ensureCollections();
await this.syncParks();
await this.syncListings();
} catch (err) {
this.logger.warn(`Typesense industrial init failed (non-fatal): ${err}`, 'TypesenseIndustrial');
}
@@ -172,6 +195,105 @@ export class TypesenseIndustrialService implements OnModuleInit {
}
}
async syncListings(): Promise<void> {
if (!this.client) return;
const listings = await this.prisma.$queryRaw<RawIndustrialListing[]>`
SELECT l.id, l.title, l.description, l."parkId", p.name as "parkName",
l."propertyType"::text, l."leaseType"::text, p.province, p.region::text,
l."areaM2", l."priceUsdM2", l."ceilingHeightM", l."floorLoadTonM2",
p."targetIndustries",
ST_Y(p.location::geometry) as lat, ST_X(p.location::geometry) as lng,
p."occupancyRate", l.status::text, l."publishedAt"
FROM "IndustrialListing" l
JOIN "IndustrialPark" p ON p.id = l."parkId"
WHERE l.status != 'EXPIRED'
`;
if (listings.length === 0) return;
const docs = listings.map((l) => ({
id: l.id,
listingId: l.id,
title: l.title,
description: l.description ?? undefined,
parkName: l.parkName,
parkId: l.parkId,
propertyType: l.propertyType.toLowerCase(),
leaseType: l.leaseType.toLowerCase(),
province: l.province,
region: l.region.toLowerCase(),
areaM2: l.areaM2,
priceUsdM2: l.priceUsdM2 ?? undefined,
ceilingHeightM: l.ceilingHeightM ?? undefined,
floorLoadTonM2: l.floorLoadTonM2 ?? undefined,
targetIndustries: l.targetIndustries ?? [],
location: [Number(l.lat), Number(l.lng)],
occupancyRate: l.occupancyRate,
status: l.status.toLowerCase(),
publishedAt: l.publishedAt ? Math.floor(l.publishedAt.getTime() / 1000) : undefined,
}));
try {
const jsonl = docs.map((d) => JSON.stringify(d)).join('\n');
await this.client.collections(INDUSTRIAL_LISTINGS_COLLECTION).documents().import(jsonl, { action: 'upsert' });
this.logger.log(`Synced ${docs.length} listings to Typesense`, 'TypesenseIndustrial');
} catch (err) {
this.logger.warn(`Listing sync error: ${err}`, 'TypesenseIndustrial');
}
}
async indexListing(listingId: string): Promise<void> {
if (!this.client) return;
const [listing] = await this.prisma.$queryRaw<RawIndustrialListing[]>`
SELECT l.id, l.title, l.description, l."parkId", p.name as "parkName",
l."propertyType"::text, l."leaseType"::text, p.province, p.region::text,
l."areaM2", l."priceUsdM2", l."ceilingHeightM", l."floorLoadTonM2",
p."targetIndustries",
ST_Y(p.location::geometry) as lat, ST_X(p.location::geometry) as lng,
p."occupancyRate", l.status::text, l."publishedAt"
FROM "IndustrialListing" l
JOIN "IndustrialPark" p ON p.id = l."parkId"
WHERE l.id = ${listingId}
`;
if (!listing) return;
const doc = {
id: listing.id,
listingId: listing.id,
title: listing.title,
description: listing.description ?? undefined,
parkName: listing.parkName,
parkId: listing.parkId,
propertyType: listing.propertyType.toLowerCase(),
leaseType: listing.leaseType.toLowerCase(),
province: listing.province,
region: listing.region.toLowerCase(),
areaM2: listing.areaM2,
priceUsdM2: listing.priceUsdM2 ?? undefined,
ceilingHeightM: listing.ceilingHeightM ?? undefined,
floorLoadTonM2: listing.floorLoadTonM2 ?? undefined,
targetIndustries: listing.targetIndustries ?? [],
location: [Number(listing.lat), Number(listing.lng)],
occupancyRate: listing.occupancyRate,
status: listing.status.toLowerCase(),
publishedAt: listing.publishedAt ? Math.floor(listing.publishedAt.getTime() / 1000) : undefined,
};
await this.client.collections(INDUSTRIAL_LISTINGS_COLLECTION).documents().upsert(doc);
}
async deleteListing(listingId: string): Promise<void> {
if (!this.client) return;
try {
await this.client.collections(INDUSTRIAL_LISTINGS_COLLECTION).documents(listingId).delete();
} catch {
// Document may not exist in Typesense
}
}
async indexPark(parkId: string): Promise<void> {
if (!this.client) return;

View File

@@ -0,0 +1,147 @@
import { Body, Controller, Delete, 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 { CurrentUser, JwtAuthGuard, type JwtPayload } from '@modules/auth';
import { NotFoundException } from '@modules/shared';
import { CreateIndustrialListingCommand } from '../../application/commands/create-industrial-listing/create-industrial-listing.command';
import { DeleteIndustrialListingCommand } from '../../application/commands/delete-industrial-listing/delete-industrial-listing.command';
import { UpdateIndustrialListingCommand } from '../../application/commands/update-industrial-listing/update-industrial-listing.command';
import { GetIndustrialListingQuery } from '../../application/queries/get-industrial-listing/get-industrial-listing.query';
import { ListIndustrialListingsQuery } from '../../application/queries/list-industrial-listings/list-industrial-listings.query';
import { type CreateIndustrialListingDto } from '../dto/create-industrial-listing.dto';
import { type SearchIndustrialListingsDto } from '../dto/search-industrial-listings.dto';
import { type UpdateIndustrialListingDto } from '../dto/update-industrial-listing.dto';
@ApiTags('industrial-listings')
@Controller('industrial')
export class IndustrialListingsController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
// ── Public endpoints ──────────────────────────────────────────────
@ApiOperation({ summary: 'Danh sách BĐS công nghiệp', description: 'Tìm kiếm và lọc tin đăng BĐS công nghiệp' })
@ApiResponse({ status: 200, description: 'Danh sách tin đăng phân trang' })
@Get('listings')
async listListings(@Query() dto: SearchIndustrialListingsDto) {
return this.queryBus.execute(
new ListIndustrialListingsQuery(
dto.parkId,
dto.propertyType,
dto.leaseType,
dto.status,
dto.minAreaM2,
dto.maxAreaM2,
dto.minPriceUsdM2,
dto.maxPriceUsdM2,
dto.q,
dto.page ?? 1,
dto.limit ?? 20,
),
);
}
@ApiOperation({ summary: 'Chi tiết tin đăng', description: 'Xem chi tiết tin đăng BĐS công nghiệp' })
@ApiResponse({ status: 200, description: 'Thông tin chi tiết tin đăng' })
@ApiResponse({ status: 404, description: 'Không tìm thấy tin đăng' })
@Get('listings/:id')
async getListing(@Param('id') id: string) {
const result = await this.queryBus.execute(new GetIndustrialListingQuery(id));
if (!result) {
throw new NotFoundException('Industrial listing', id);
}
return result;
}
// ── Authenticated endpoints ───────────────────────────────────────
@ApiOperation({ summary: 'Tạo tin đăng', description: 'Tạo mới tin đăng BĐS công nghiệp' })
@ApiResponse({ status: 201, description: 'Tin đăng đã tạo' })
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard)
@Post('listings')
async createListing(@Body() dto: CreateIndustrialListingDto, @CurrentUser() user: JwtPayload) {
return this.commandBus.execute(
new CreateIndustrialListingCommand(
dto.parkId,
user.sub,
dto.agentId ?? null,
dto.propertyType,
dto.leaseType,
dto.title,
dto.description ?? null,
dto.areaM2,
dto.ceilingHeightM ?? null,
dto.floorLoadTonM2 ?? null,
dto.columnSpacingM ?? null,
dto.dockCount ?? null,
dto.craneCapacityTon ?? null,
dto.hasMezzanine ?? false,
dto.hasOfficeArea ?? false,
dto.officeAreaM2 ?? null,
dto.priceUsdM2 ?? null,
dto.pricingUnit ?? null,
dto.totalLeasePrice ?? null,
dto.managementFee ?? null,
dto.depositMonths ?? null,
dto.minLeaseYears ?? null,
dto.maxLeaseYears ?? null,
dto.leaseExpiry ? new Date(dto.leaseExpiry) : null,
dto.availableFrom ? new Date(dto.availableFrom) : null,
dto.powerCapacityKva ?? null,
dto.waterSupplyM3Day ?? null,
dto.media ?? null,
),
);
}
@ApiOperation({ summary: 'Cập nhật tin đăng', description: 'Cập nhật thông tin tin đăng BĐS công nghiệp' })
@ApiResponse({ status: 200, description: 'Tin đăng đã cập nhật' })
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard)
@Patch('listings/:id')
async updateListing(@Param('id') id: string, @Body() dto: UpdateIndustrialListingDto) {
return this.commandBus.execute(
new UpdateIndustrialListingCommand(
id,
dto.propertyType,
dto.leaseType,
dto.status,
dto.title,
dto.description,
dto.areaM2,
dto.ceilingHeightM,
dto.floorLoadTonM2,
dto.columnSpacingM,
dto.dockCount,
dto.craneCapacityTon,
dto.hasMezzanine,
dto.hasOfficeArea,
dto.officeAreaM2,
dto.priceUsdM2,
dto.pricingUnit,
dto.totalLeasePrice,
dto.managementFee,
dto.depositMonths,
dto.minLeaseYears,
dto.maxLeaseYears,
dto.leaseExpiry ? new Date(dto.leaseExpiry) : dto.leaseExpiry as undefined,
dto.availableFrom ? new Date(dto.availableFrom) : dto.availableFrom as undefined,
dto.powerCapacityKva,
dto.waterSupplyM3Day,
dto.media,
),
);
}
@ApiOperation({ summary: 'Xóa tin đăng', description: 'Xóa mềm tin đăng BĐS công nghiệp' })
@ApiResponse({ status: 200, description: 'Tin đăng đã xóa' })
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard)
@Delete('listings/:id')
async deleteListing(@Param('id') id: string) {
return this.commandBus.execute(new DeleteIndustrialListingCommand(id));
}
}

View File

@@ -0,0 +1,165 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IndustrialLeaseType, IndustrialPropertyType } from '@prisma/client';
import { Type } from 'class-transformer';
import {
IsString,
IsNumber,
IsEnum,
IsOptional,
IsBoolean,
IsArray,
IsObject,
IsDateString,
Min,
MaxLength,
} from 'class-validator';
export class CreateIndustrialListingDto {
@ApiProperty({ description: 'ID khu công nghiệp' })
@IsString()
parkId!: string;
@ApiPropertyOptional({ description: 'ID môi giới' })
@IsOptional()
@IsString()
agentId?: string;
@ApiProperty({ enum: IndustrialPropertyType, example: 'READY_BUILT_FACTORY' })
@IsEnum(IndustrialPropertyType)
propertyType!: IndustrialPropertyType;
@ApiProperty({ enum: IndustrialLeaseType, example: 'FACTORY_LEASE' })
@IsEnum(IndustrialLeaseType)
leaseType!: IndustrialLeaseType;
@ApiProperty({ example: 'Nhà xưởng 5000m² tại KCN VSIP', description: 'Tiêu đề' })
@IsString()
@MaxLength(300)
title!: string;
@ApiPropertyOptional({ description: 'Mô tả chi tiết' })
@IsOptional()
@IsString()
description?: string;
@ApiProperty({ example: 5000, description: 'Diện tích (m²)' })
@IsNumber()
@Type(() => Number)
@Min(0)
areaM2!: number;
@ApiPropertyOptional({ example: 12, description: 'Chiều cao trần (m)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
ceilingHeightM?: number;
@ApiPropertyOptional({ example: 3, description: 'Tải trọng sàn (tấn/m²)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
floorLoadTonM2?: number;
@ApiPropertyOptional({ example: 12, description: 'Khoảng cách cột (m)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
columnSpacingM?: number;
@ApiPropertyOptional({ example: 4, description: 'Số dock' })
@IsOptional()
@IsNumber()
@Type(() => Number)
dockCount?: number;
@ApiPropertyOptional({ example: 10, description: 'Tải trọng cẩu trục (tấn)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
craneCapacityTon?: number;
@ApiPropertyOptional({ example: false })
@IsOptional()
@IsBoolean()
hasMezzanine?: boolean;
@ApiPropertyOptional({ example: true })
@IsOptional()
@IsBoolean()
hasOfficeArea?: boolean;
@ApiPropertyOptional({ example: 200, description: 'Diện tích văn phòng (m²)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
officeAreaM2?: number;
@ApiPropertyOptional({ example: 5.5, description: 'Giá thuê (USD/m²)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
priceUsdM2?: number;
@ApiPropertyOptional({ example: 'usd/m2/month', description: 'Đơn vị giá' })
@IsOptional()
@IsString()
pricingUnit?: string;
@ApiPropertyOptional({ example: 27500, description: 'Tổng giá thuê' })
@IsOptional()
@IsNumber()
@Type(() => Number)
totalLeasePrice?: number;
@ApiPropertyOptional({ example: 0.6, description: 'Phí quản lý' })
@IsOptional()
@IsNumber()
@Type(() => Number)
managementFee?: number;
@ApiPropertyOptional({ example: 3, description: 'Số tháng đặt cọc' })
@IsOptional()
@IsNumber()
@Type(() => Number)
depositMonths?: number;
@ApiPropertyOptional({ example: 3, description: 'Thời hạn thuê tối thiểu (năm)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
minLeaseYears?: number;
@ApiPropertyOptional({ example: 50, description: 'Thời hạn thuê tối đa (năm)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
maxLeaseYears?: number;
@ApiPropertyOptional({ description: 'Ngày hết hạn thuê' })
@IsOptional()
@IsDateString()
leaseExpiry?: string;
@ApiPropertyOptional({ description: 'Ngày có thể bắt đầu thuê' })
@IsOptional()
@IsDateString()
availableFrom?: string;
@ApiPropertyOptional({ example: 500, description: 'Công suất điện (KVA)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
powerCapacityKva?: number;
@ApiPropertyOptional({ example: 100, description: 'Cấp nước (m³/ngày)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
waterSupplyM3Day?: number;
@ApiPropertyOptional({ description: 'Hình ảnh / tài liệu' })
@IsOptional()
@IsArray()
@IsObject({ each: true })
media?: Record<string, unknown>[];
}

View File

@@ -0,0 +1,71 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IndustrialLeaseType, IndustrialListingStatus, IndustrialPropertyType } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
export class SearchIndustrialListingsDto {
@ApiPropertyOptional({ description: 'Lọc theo KCN' })
@IsOptional()
@IsString()
parkId?: string;
@ApiPropertyOptional({ enum: IndustrialPropertyType })
@IsOptional()
@IsEnum(IndustrialPropertyType)
propertyType?: IndustrialPropertyType;
@ApiPropertyOptional({ enum: IndustrialLeaseType })
@IsOptional()
@IsEnum(IndustrialLeaseType)
leaseType?: IndustrialLeaseType;
@ApiPropertyOptional({ enum: IndustrialListingStatus })
@IsOptional()
@IsEnum(IndustrialListingStatus)
status?: IndustrialListingStatus;
@ApiPropertyOptional({ example: 1000, description: 'Diện tích tối thiểu (m²)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(0)
minAreaM2?: number;
@ApiPropertyOptional({ example: 10000, description: 'Diện tích tối đa (m²)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
maxAreaM2?: number;
@ApiPropertyOptional({ example: 3, description: 'Giá tối thiểu (USD/m²)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
minPriceUsdM2?: number;
@ApiPropertyOptional({ example: 10, description: 'Giá tối đa (USD/m²)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
maxPriceUsdM2?: number;
@ApiPropertyOptional({ example: 'nhà xưởng', description: 'Từ khóa tìm kiếm' })
@IsOptional()
@IsString()
q?: string;
@ApiPropertyOptional({ example: 1, default: 1 })
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(1)
page?: number;
@ApiPropertyOptional({ example: 20, default: 20 })
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(1)
@Max(100)
limit?: number;
}

View File

@@ -0,0 +1,165 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IndustrialLeaseType, IndustrialListingStatus, IndustrialPropertyType } from '@prisma/client';
import { Type } from 'class-transformer';
import {
IsString,
IsNumber,
IsEnum,
IsOptional,
IsBoolean,
IsArray,
IsObject,
IsDateString,
Min,
MaxLength,
} from 'class-validator';
export class UpdateIndustrialListingDto {
@ApiPropertyOptional({ enum: IndustrialPropertyType })
@IsOptional()
@IsEnum(IndustrialPropertyType)
propertyType?: IndustrialPropertyType;
@ApiPropertyOptional({ enum: IndustrialLeaseType })
@IsOptional()
@IsEnum(IndustrialLeaseType)
leaseType?: IndustrialLeaseType;
@ApiPropertyOptional({ enum: IndustrialListingStatus })
@IsOptional()
@IsEnum(IndustrialListingStatus)
status?: IndustrialListingStatus;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(300)
title?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(0)
areaM2?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
ceilingHeightM?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
floorLoadTonM2?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
columnSpacingM?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
dockCount?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
craneCapacityTon?: number;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
hasMezzanine?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
hasOfficeArea?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
officeAreaM2?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
priceUsdM2?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
pricingUnit?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
totalLeasePrice?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
managementFee?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
depositMonths?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
minLeaseYears?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
maxLeaseYears?: number;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
leaseExpiry?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
availableFrom?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
powerCapacityKva?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
waterSupplyM3Day?: number;
@ApiPropertyOptional()
@IsOptional()
@IsArray()
@IsObject({ each: true })
media?: Record<string, unknown>[];
}