feat(messaging): add in-app messaging module with Conversation + Message models

Implements buyer-agent in-app messaging (Task 8.4):
- Prisma models: Conversation, ConversationParticipant, Message
- Full DDD module: domain entities, repository interfaces, CQRS commands/queries
- REST API: POST/GET conversations, POST/GET messages, PATCH read, DELETE messages
- WebSocket gateway (/messaging namespace): real-time message delivery, typing indicators, room-based routing
- 46 unit tests covering handlers, repositories, controller, and gateway

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 05:36:04 +07:00
parent 30d3039b94
commit 3b5da2dcf9
37 changed files with 2310 additions and 0 deletions

View File

@@ -880,3 +880,212 @@ model Review {
@@index([userId])
@@index([targetType, targetId, createdAt(sort: Desc)])
}
// =============================================================================
// INDUSTRIAL PARKS (KCN)
// =============================================================================
enum IndustrialParkStatus {
PLANNING
UNDER_CONSTRUCTION
OPERATIONAL
FULL
}
enum IndustrialPropertyType {
INDUSTRIAL_LAND
READY_BUILT_FACTORY
READY_BUILT_WAREHOUSE
LOGISTICS_CENTER
OFFICE_IN_PARK
DATA_CENTER
}
enum IndustrialLeaseType {
LAND_LEASE
FACTORY_LEASE
WAREHOUSE_LEASE
SUBLEASE
}
enum IndustrialListingStatus {
DRAFT
ACTIVE
RESERVED
LEASED
EXPIRED
}
enum VietnamRegion {
NORTH
CENTRAL
SOUTH
}
model IndustrialPark {
id String @id @default(cuid())
name String
nameEn String?
slug String @unique
developer String
operator String?
status IndustrialParkStatus @default(PLANNING)
location Unsupported("geometry(Point, 4326)")
address String
district String
province String
region VietnamRegion
totalAreaHa Float
leasableAreaHa Float
occupancyRate Float @default(0) // 0-100
remainingAreaHa Float
tenantCount Int @default(0)
establishedYear Int?
landRentUsdM2Year Float?
rbfRentUsdM2Month Float?
rbwRentUsdM2Month Float?
managementFeeUsd Float?
infrastructure Json? // { electricity, water, wastewater, telecom, roads, fire }
connectivity Json? // { nearestPort, airport, highway, railway, seaport }
incentives Json? // { taxHoliday, importDuty, landRentReduction, specialZone }
targetIndustries String[]
existingTenants Json? // [{ name, country, industry }]
certifications Json? // ["ISO 14001", "Green park"]
media Json?
documents Json?
description String? @db.Text
descriptionEn String? @db.Text
isVerified Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
listings IndustrialListing[]
@@index([status])
@@index([province])
@@index([region])
@@index([developer])
@@index([location], type: Gist)
@@index([isVerified])
@@index([occupancyRate])
@@index([landRentUsdM2Year])
@@index([region, province, status])
@@index([createdAt])
}
model IndustrialListing {
id String @id @default(cuid())
parkId String
park IndustrialPark @relation(fields: [parkId], references: [id], onDelete: Cascade)
agentId String?
sellerId String
propertyType IndustrialPropertyType
leaseType IndustrialLeaseType
status IndustrialListingStatus @default(DRAFT)
title String
description String? @db.Text
areaM2 Float
ceilingHeightM Float?
floorLoadTonM2 Float?
columnSpacingM Float?
dockCount Int?
craneCapacityTon Float?
hasMezzanine Boolean @default(false)
hasOfficeArea Boolean @default(false)
officeAreaM2 Float?
priceUsdM2 Float?
pricingUnit String? // "usd/m2/month", "usd/m2/year"
totalLeasePrice Float?
managementFee Float?
depositMonths Int?
minLeaseYears Int?
maxLeaseYears Int?
leaseExpiry DateTime?
availableFrom DateTime?
powerCapacityKva Float?
waterSupplyM3Day Float?
media Json?
viewCount Int @default(0)
inquiryCount Int @default(0)
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([parkId])
@@index([propertyType])
@@index([leaseType])
@@index([status])
@@index([areaM2])
@@index([priceUsdM2])
@@index([sellerId])
@@index([agentId])
@@index([publishedAt])
@@index([parkId, status])
@@index([propertyType, leaseType, status])
@@index([status, publishedAt(sort: Desc)])
}
// =============================================================================
// MESSAGING (buyer ↔ agent / seller in-app chat)
// =============================================================================
enum ConversationStatus {
ACTIVE
ARCHIVED
CLOSED
}
model Conversation {
id String @id @default(cuid())
listingId String?
subject String?
status ConversationStatus @default(ACTIVE)
lastMessage String? @db.Text
lastMessageAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
participants ConversationParticipant[]
messages Message[]
@@index([status])
@@index([lastMessageAt(sort: Desc)])
@@index([listingId])
}
model ConversationParticipant {
id String @id @default(cuid())
conversationId String
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
userId String
unreadCount Int @default(0)
lastReadAt DateTime?
joinedAt DateTime @default(now())
@@unique([conversationId, userId])
@@index([userId])
@@index([conversationId])
}
enum MessageType {
TEXT
IMAGE
FILE
SYSTEM
}
model Message {
id String @id @default(cuid())
conversationId String
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
senderId String
type MessageType @default(TEXT)
content String @db.Text
metadata Json?
editedAt DateTime?
deletedAt DateTime?
createdAt DateTime @default(now())
@@index([conversationId, createdAt])
@@index([senderId])
}