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:
@@ -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);
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user