diff --git a/apps/api/src/modules/industrial/application/commands/create-industrial-listing/create-industrial-listing.command.ts b/apps/api/src/modules/industrial/application/commands/create-industrial-listing/create-industrial-listing.command.ts new file mode 100644 index 0000000..14c8f4d --- /dev/null +++ b/apps/api/src/modules/industrial/application/commands/create-industrial-listing/create-industrial-listing.command.ts @@ -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[] | null, + ) {} +} diff --git a/apps/api/src/modules/industrial/application/commands/create-industrial-listing/create-industrial-listing.handler.ts b/apps/api/src/modules/industrial/application/commands/create-industrial-listing/create-industrial-listing.handler.ts new file mode 100644 index 0000000..c7af99c --- /dev/null +++ b/apps/api/src/modules/industrial/application/commands/create-industrial-listing/create-industrial-listing.handler.ts @@ -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 { + 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 }; + } +} diff --git a/apps/api/src/modules/industrial/application/commands/delete-industrial-listing/delete-industrial-listing.command.ts b/apps/api/src/modules/industrial/application/commands/delete-industrial-listing/delete-industrial-listing.command.ts new file mode 100644 index 0000000..292f734 --- /dev/null +++ b/apps/api/src/modules/industrial/application/commands/delete-industrial-listing/delete-industrial-listing.command.ts @@ -0,0 +1,5 @@ +export class DeleteIndustrialListingCommand { + constructor( + public readonly id: string, + ) {} +} diff --git a/apps/api/src/modules/industrial/application/commands/delete-industrial-listing/delete-industrial-listing.handler.ts b/apps/api/src/modules/industrial/application/commands/delete-industrial-listing/delete-industrial-listing.handler.ts new file mode 100644 index 0000000..7927ebe --- /dev/null +++ b/apps/api/src/modules/industrial/application/commands/delete-industrial-listing/delete-industrial-listing.handler.ts @@ -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 { + constructor( + @Inject(INDUSTRIAL_LISTING_REPOSITORY) + private readonly repo: IIndustrialListingRepository, + private readonly typesense: TypesenseIndustrialService, + ) {} + + async execute(cmd: DeleteIndustrialListingCommand): Promise { + 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(() => {}); + } +} diff --git a/apps/api/src/modules/industrial/application/commands/update-industrial-listing/update-industrial-listing.command.ts b/apps/api/src/modules/industrial/application/commands/update-industrial-listing/update-industrial-listing.command.ts new file mode 100644 index 0000000..72342e7 --- /dev/null +++ b/apps/api/src/modules/industrial/application/commands/update-industrial-listing/update-industrial-listing.command.ts @@ -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[] | null, + ) {} +} diff --git a/apps/api/src/modules/industrial/application/commands/update-industrial-listing/update-industrial-listing.handler.ts b/apps/api/src/modules/industrial/application/commands/update-industrial-listing/update-industrial-listing.handler.ts new file mode 100644 index 0000000..9edaaae --- /dev/null +++ b/apps/api/src/modules/industrial/application/commands/update-industrial-listing/update-industrial-listing.handler.ts @@ -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 { + constructor( + @Inject(INDUSTRIAL_LISTING_REPOSITORY) + private readonly repo: IIndustrialListingRepository, + private readonly typesense: TypesenseIndustrialService, + ) {} + + async execute(cmd: UpdateIndustrialListingCommand): Promise { + 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(() => {}); + } +} diff --git a/apps/api/src/modules/industrial/application/queries/get-industrial-listing/get-industrial-listing.handler.ts b/apps/api/src/modules/industrial/application/queries/get-industrial-listing/get-industrial-listing.handler.ts new file mode 100644 index 0000000..2fa23c3 --- /dev/null +++ b/apps/api/src/modules/industrial/application/queries/get-industrial-listing/get-industrial-listing.handler.ts @@ -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 { + constructor( + @Inject(INDUSTRIAL_LISTING_REPOSITORY) + private readonly repo: IIndustrialListingRepository, + ) {} + + async execute(query: GetIndustrialListingQuery): Promise { + return this.repo.findDetailById(query.id); + } +} diff --git a/apps/api/src/modules/industrial/application/queries/get-industrial-listing/get-industrial-listing.query.ts b/apps/api/src/modules/industrial/application/queries/get-industrial-listing/get-industrial-listing.query.ts new file mode 100644 index 0000000..b761755 --- /dev/null +++ b/apps/api/src/modules/industrial/application/queries/get-industrial-listing/get-industrial-listing.query.ts @@ -0,0 +1,5 @@ +export class GetIndustrialListingQuery { + constructor( + public readonly id: string, + ) {} +} diff --git a/apps/api/src/modules/industrial/application/queries/list-industrial-listings/list-industrial-listings.handler.ts b/apps/api/src/modules/industrial/application/queries/list-industrial-listings/list-industrial-listings.handler.ts new file mode 100644 index 0000000..4de2ef8 --- /dev/null +++ b/apps/api/src/modules/industrial/application/queries/list-industrial-listings/list-industrial-listings.handler.ts @@ -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 { + constructor( + @Inject(INDUSTRIAL_LISTING_REPOSITORY) + private readonly repo: IIndustrialListingRepository, + ) {} + + async execute(query: ListIndustrialListingsQuery): Promise> { + 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, + }); + } +} diff --git a/apps/api/src/modules/industrial/application/queries/list-industrial-listings/list-industrial-listings.query.ts b/apps/api/src/modules/industrial/application/queries/list-industrial-listings/list-industrial-listings.query.ts new file mode 100644 index 0000000..5279d0e --- /dev/null +++ b/apps/api/src/modules/industrial/application/queries/list-industrial-listings/list-industrial-listings.query.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/industrial/domain/entities/industrial-listing.entity.ts b/apps/api/src/modules/industrial/domain/entities/industrial-listing.entity.ts new file mode 100644 index 0000000..2346580 --- /dev/null +++ b/apps/api/src/modules/industrial/domain/entities/industrial-listing.entity.ts @@ -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[] | null; + viewCount: number; + inquiryCount: number; + publishedAt: Date | null; +} + +export class IndustrialListingEntity extends AggregateRoot { + 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[] | 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>): 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(); + } +} diff --git a/apps/api/src/modules/industrial/domain/repositories/industrial-listing.repository.ts b/apps/api/src/modules/industrial/domain/repositories/industrial-listing.repository.ts new file mode 100644 index 0000000..f4ef7b8 --- /dev/null +++ b/apps/api/src/modules/industrial/domain/repositories/industrial-listing.repository.ts @@ -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 { + 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[] | null; + viewCount: number; + inquiryCount: number; + publishedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface IIndustrialListingRepository { + findById(id: string): Promise; + findDetailById(id: string): Promise; + save(entity: IndustrialListingEntity): Promise; + update(entity: IndustrialListingEntity): Promise; + search(params: IndustrialListingSearchParams): Promise>; +} diff --git a/apps/api/src/modules/industrial/industrial.module.ts b/apps/api/src/modules/industrial/industrial.module.ts index 153cd5d..b2a14cb 100644 --- a/apps/api/src/modules/industrial/industrial.module.ts +++ b/apps/api/src/modules/industrial/industrial.module.ts @@ -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 {} diff --git a/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-listing.repository.ts b/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-listing.repository.ts new file mode 100644 index 0000000..a185f73 --- /dev/null +++ b/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-listing.repository.ts @@ -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 { + const rows = await this.prisma.$queryRaw` + SELECT * FROM "IndustrialListing" WHERE id = ${id} LIMIT 1 + `; + return rows[0] ? this.toDomain(rows[0]) : null; + } + + async findDetailById(id: string): Promise { + const rows = await this.prisma.$queryRaw` + 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 { + 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 { + 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> { + 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( + `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[] | 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[] | 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; +} diff --git a/apps/api/src/modules/industrial/infrastructure/services/typesense-industrial.service.ts b/apps/api/src/modules/industrial/infrastructure/services/typesense-industrial.service.ts index 1d23d29..7ae2657 100644 --- a/apps/api/src/modules/industrial/infrastructure/services/typesense-industrial.service.ts +++ b/apps/api/src/modules/industrial/infrastructure/services/typesense-industrial.service.ts @@ -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 { + if (!this.client) return; + + const listings = await this.prisma.$queryRaw` + 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 { + if (!this.client) return; + + const [listing] = await this.prisma.$queryRaw` + 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 { + 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 { if (!this.client) return; diff --git a/apps/api/src/modules/industrial/presentation/controllers/industrial-listings.controller.ts b/apps/api/src/modules/industrial/presentation/controllers/industrial-listings.controller.ts new file mode 100644 index 0000000..eef6e1a --- /dev/null +++ b/apps/api/src/modules/industrial/presentation/controllers/industrial-listings.controller.ts @@ -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)); + } +} diff --git a/apps/api/src/modules/industrial/presentation/dto/create-industrial-listing.dto.ts b/apps/api/src/modules/industrial/presentation/dto/create-industrial-listing.dto.ts new file mode 100644 index 0000000..5d3dd7d --- /dev/null +++ b/apps/api/src/modules/industrial/presentation/dto/create-industrial-listing.dto.ts @@ -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[]; +} diff --git a/apps/api/src/modules/industrial/presentation/dto/search-industrial-listings.dto.ts b/apps/api/src/modules/industrial/presentation/dto/search-industrial-listings.dto.ts new file mode 100644 index 0000000..ada6609 --- /dev/null +++ b/apps/api/src/modules/industrial/presentation/dto/search-industrial-listings.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/industrial/presentation/dto/update-industrial-listing.dto.ts b/apps/api/src/modules/industrial/presentation/dto/update-industrial-listing.dto.ts new file mode 100644 index 0000000..2d0765c --- /dev/null +++ b/apps/api/src/modules/industrial/presentation/dto/update-industrial-listing.dto.ts @@ -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[]; +}