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:
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class DeleteIndustrialListingCommand {
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class GetIndustrialListingQuery {
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>>;
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>[];
|
||||
}
|
||||
Reference in New Issue
Block a user