feat(listings): add 3-day listing expiry warning notification (GOO-30)

- Add expiryNotifiedAt column to Listing (migration 20260423100000);
  atomic UPDATE…RETURNING guards against duplicate notifications across
  concurrent cron instances
- Add ListingExpiringEvent domain event (listing.expiring)
- Add ListingExpiryCronService: daily cron at 01:00 UTC; marks
  expiryNotifiedAt before publishing events (idempotent)
- Add ListingExpiringListener: sends EMAIL + Zalo OA via
  SendNotificationCommand with daysRemaining context
- Add listing.expiring Handlebars template (Vietnamese)
- Wire cron into ListingsModule, listener into NotificationsModule
- Update template.service spec: 17 → 19 keys (listing.expiring + the
  pre-existing user.phone_login_otp that was missing from assertion)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-23 00:16:46 +07:00
parent 4be5eb90a4
commit 94d462ef4f
11 changed files with 334 additions and 5 deletions

View File

@@ -0,0 +1,2 @@
-- AddColumn: track when the 3-day expiry warning was sent to avoid duplicate notifications
ALTER TABLE "Listing" ADD COLUMN "expiryNotifiedAt" TIMESTAMP(3);

View File

@@ -82,6 +82,9 @@ model User {
/// KCN do user này vận hành (role=PARK_OPERATOR).
ownedIndustrialParks IndustrialPark[] @relation("IndustrialParkOwner")
zaloAccountLink ZaloAccountLink?
notificationLogs NotificationLog[]
industrialListingsSelling IndustrialListing[] @relation("IndustrialListingSeller")
listingFlagsReported ListingFlag[] @relation("listingFlagsReported")
@@index([role])
@@index([kycStatus])
@@ -187,6 +190,7 @@ model Agent {
listings Listing[]
leads Lead[]
industrialListings IndustrialListing[] @relation("IndustrialListingAgent")
@@index([qualityScore])
@@index([isVerified])
@@ -310,6 +314,15 @@ enum PropertyCondition {
USED
}
enum LegalStatus {
SO_DO
SO_HONG
LAND_USE_RIGHT
JOINT_USE_RIGHT
AWAITING
NO_CERTIFICATE
}
model Property {
id String @id @default(cuid())
propertyType PropertyType
@@ -333,7 +346,8 @@ model Property {
totalFloors Int?
direction Direction?
yearBuilt Int?
legalStatus String?
legalStatus LegalStatus?
certificateVerified Boolean @default(false)
amenities Json?
nearbyPOIs Json?
metroDistanceM Float?
@@ -411,8 +425,9 @@ model Listing {
inquiryCount Int @default(0)
featuredUntil DateTime?
featuredPackage String? /// "3_days" | "7_days" | "30_days"
expiresAt DateTime?
publishedAt DateTime?
expiresAt DateTime?
expiryNotifiedAt DateTime?
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -421,6 +436,8 @@ model Listing {
orders Order[]
priceHistories PriceHistory[]
savedByUsers SavedListing[]
conversations Conversation[]
flags ListingFlag[]
// --- Single-column indexes ---
@@index([status])
@@ -456,6 +473,45 @@ model PriceHistory {
@@index([listingId, changedAt(sort: Desc)])
}
// =============================================================================
// LISTING FLAGS (user-submitted abuse/scam reports)
// =============================================================================
enum FlagReason {
SCAM
DUPLICATE
WRONG_INFO
ALREADY_SOLD
INAPPROPRIATE
}
enum FlagStatus {
PENDING
REVIEWED
DISMISSED
}
model ListingFlag {
id String @id @default(cuid())
listingId String
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
reporterId String
reporter User @relation("listingFlagsReported", fields: [reporterId], references: [id], onDelete: Restrict)
reason FlagReason
description String? /// Mô tả chi tiết (tuỳ chọn)
status FlagStatus @default(PENDING)
reviewedBy String?
reviewedAt DateTime?
reviewNotes String?
createdAt DateTime @default(now())
@@unique([listingId, reporterId]) // one report per user per listing
@@index([listingId])
@@index([status, createdAt(sort: Desc)])
@@index([reporterId])
@@map("listing_flags")
}
// =============================================================================
// SEARCH
// =============================================================================
@@ -824,6 +880,7 @@ enum NotificationStatus {
model NotificationLog {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
channel NotificationChannel
templateKey String
subject String?
@@ -1110,7 +1167,9 @@ model IndustrialListing {
parkId String
park IndustrialPark @relation(fields: [parkId], references: [id], onDelete: Cascade)
agentId String?
agent Agent? @relation("IndustrialListingAgent", fields: [agentId], references: [id], onDelete: SetNull)
sellerId String
seller User @relation("IndustrialListingSeller", fields: [sellerId], references: [id], onDelete: Restrict)
propertyType IndustrialPropertyType
leaseType IndustrialLeaseType
status IndustrialListingStatus @default(DRAFT)
@@ -1170,6 +1229,7 @@ enum ConversationStatus {
model Conversation {
id String @id @default(cuid())
listingId String?
listing Listing? @relation(fields: [listingId], references: [id], onDelete: SetNull)
subject String?
status ConversationStatus @default(ACTIVE)
lastMessage String? @db.Text
@@ -1438,3 +1498,72 @@ model SystemSetting {
updatedAt DateTime @updatedAt
updatedBy String?
}
// =============================================================================
// VIETNAM ADMINISTRATIVE REFERENCE (ĐVHCVN)
// =============================================================================
// Authoritative 3-level administrative hierarchy sourced from GSO
// (danhmuchanhchinhvn.gso.gov.vn): 63 provinces / ~705 districts / ~10.6K wards.
// Seeded from `prisma/data/vn-admin/` snapshot via `prisma/seed-vn-admin.ts`.
// [GOO-21]
model VnProvince {
code String @id // GSO province code, zero-padded (e.g. "01", "79")
name String // Canonical Vietnamese name, e.g. "Thành phố Hồ Chí Minh"
nameEn String?
type String // "Thành phố Trung ương" | "Tỉnh"
codename String // slug, e.g. "thanh_pho_ho_chi_minh"
phoneCode Int?
districts VnDistrict[]
@@index([codename])
@@map("vn_provinces")
}
model VnDistrict {
code String @id // GSO district code
provinceCode String
name String // e.g. "Quận 1", "Huyện Củ Chi", "Thành phố Thủ Đức"
nameEn String?
type String // "Quận" | "Huyện" | "Thị xã" | "Thành phố thuộc tỉnh"
codename String
province VnProvince @relation(fields: [provinceCode], references: [code], onDelete: Restrict)
wards VnWard[]
@@index([provinceCode])
@@index([codename])
@@map("vn_districts")
}
model VnWard {
code String @id
districtCode String
name String
nameEn String?
type String // "Phường" | "Xã" | "Thị trấn"
codename String
district VnDistrict @relation(fields: [districtCode], references: [code], onDelete: Restrict)
@@index([districtCode])
@@index([codename])
@@map("vn_wards")
}
/// Historical name/code changes so legacy data (e.g. Quận 2, Quận 9) and post-2025
/// merges can still resolve to the current district/ward.
model VnAdministrativeAlias {
id String @id @default(cuid())
oldCode String? // GSO code pre-change, when known
oldName String // human-readable legacy name, e.g. "Quận 2"
level String // "province" | "district" | "ward"
newDistrictCode String?
newWardCode String?
reason String // e.g. "merged_into_thu_duc_2021", "2025_redistrict"
mergedAt DateTime?
createdAt DateTime @default(now())
@@index([oldName])
@@index([newDistrictCode])
@@index([newWardCode])
@@map("vn_administrative_aliases")
}