From 94d462ef4f63b2ac108f4e0296ac7c2a70099a7c Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 23 Apr 2026 00:16:46 +0700 Subject: [PATCH] feat(listings): add 3-day listing expiry warning notification (GOO-30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../modules/listings/domain/events/index.ts | 1 + .../domain/events/listing-expiring.event.ts | 19 +++ apps/api/src/modules/listings/index.ts | 1 + .../cron/listing-expiry-cron.service.ts | 75 ++++++++++ .../src/modules/listings/listings.module.ts | 4 + .../listeners/listing-expiring.listener.ts | 81 +++++++++++ .../__tests__/template.service.spec.ts | 6 +- .../services/template.service.ts | 11 ++ .../notifications/notifications.module.ts | 4 + .../migration.sql | 2 + prisma/schema.prisma | 135 +++++++++++++++++- 11 files changed, 334 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/modules/listings/domain/events/listing-expiring.event.ts create mode 100644 apps/api/src/modules/listings/infrastructure/cron/listing-expiry-cron.service.ts create mode 100644 apps/api/src/modules/notifications/application/listeners/listing-expiring.listener.ts create mode 100644 prisma/migrations/20260423100000_add_listing_expiry_notified_at/migration.sql diff --git a/apps/api/src/modules/listings/domain/events/index.ts b/apps/api/src/modules/listings/domain/events/index.ts index 5cbd4bc..14f6c65 100644 --- a/apps/api/src/modules/listings/domain/events/index.ts +++ b/apps/api/src/modules/listings/domain/events/index.ts @@ -3,5 +3,6 @@ export { ListingApprovedEvent } from './listing-approved.event'; export { ListingPriceChangedEvent } from './listing-price-changed.event'; export { ListingSoldEvent } from './listing-sold.event'; export { ListingFeaturedExpiredEvent } from './listing-featured-expired.event'; +export { ListingExpiringEvent } from './listing-expiring.event'; export { ListingOwnershipTransferredEvent } from './listing-ownership-transferred.event'; export { FeaturedListingPaymentRequestedEvent } from './featured-listing-payment-requested.event'; diff --git a/apps/api/src/modules/listings/domain/events/listing-expiring.event.ts b/apps/api/src/modules/listings/domain/events/listing-expiring.event.ts new file mode 100644 index 0000000..4da8d6e --- /dev/null +++ b/apps/api/src/modules/listings/domain/events/listing-expiring.event.ts @@ -0,0 +1,19 @@ +import { type DomainEvent } from '@modules/shared'; + +/** + * Fired by the daily expiry-warning cron for each listing that expires + * within the next 3 days and has not yet received a warning notification. + */ +export class ListingExpiringEvent implements DomainEvent { + readonly eventName = 'listing.expiring'; + readonly occurredAt = new Date(); + + constructor( + /** Listing ID */ + public readonly aggregateId: string, + /** ID of the seller who owns the listing */ + public readonly sellerId: string, + /** When the listing expires */ + public readonly expiresAt: Date, + ) {} +} diff --git a/apps/api/src/modules/listings/index.ts b/apps/api/src/modules/listings/index.ts index 954a40e..1b5095e 100644 --- a/apps/api/src/modules/listings/index.ts +++ b/apps/api/src/modules/listings/index.ts @@ -21,4 +21,5 @@ export { ListingSoldEvent } from './domain/events/listing-sold.event'; export { ListingStatusChangedEvent } from './domain/events/listing-status-changed.event'; export { ListingOwnershipTransferredEvent } from './domain/events/listing-ownership-transferred.event'; export { ListingFeaturedExpiredEvent } from './domain/events/listing-featured-expired.event'; +export { ListingExpiringEvent } from './domain/events/listing-expiring.event'; export { Price } from './domain/value-objects/price.vo'; diff --git a/apps/api/src/modules/listings/infrastructure/cron/listing-expiry-cron.service.ts b/apps/api/src/modules/listings/infrastructure/cron/listing-expiry-cron.service.ts new file mode 100644 index 0000000..9529db0 --- /dev/null +++ b/apps/api/src/modules/listings/infrastructure/cron/listing-expiry-cron.service.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@nestjs/common'; +import { EventBus } from '@nestjs/cqrs'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { ListingStatus } from '@prisma/client'; +import { PrismaService, LoggerService } from '@modules/shared'; +import { ListingExpiringEvent } from '../../domain/events/listing-expiring.event'; + +/** + * Daily cron that fires a 3-day expiry warning for active listings. + * + * Design notes: + * - Runs once per day at 08:00 (Vietnam time, UTC+7 → 01:00 UTC). + * - Queries listings whose `expiresAt` falls within the next 1–3 days + * AND whose `expiryNotifiedAt` is still NULL (idempotent guard). + * - Uses a single atomic SQL UPDATE … RETURNING so concurrent instances + * cannot double-fire: only the first writer will satisfy the NULL predicate. + * - Publishes `ListingExpiringEvent` per affected listing so the + * notifications module can dispatch Zalo OA / email messages. + */ +@Injectable() +export class ListingExpiryCronService { + constructor( + private readonly prisma: PrismaService, + private readonly eventBus: EventBus, + private readonly logger: LoggerService, + ) {} + + @Cron('0 1 * * *', { name: 'listing-expiry-warning', timeZone: 'UTC' }) + async notifyExpiringListings(): Promise { + const now = new Date(); + const in3Days = new Date(now.getTime() + 3 * 24 * 60 * 60 * 1000); + + try { + // Atomically claim rows: set expiryNotifiedAt so concurrent instances skip them. + const expiring = await this.prisma.$queryRaw< + Array<{ id: string; sellerId: string; expiresAt: Date }> + >` + UPDATE "Listing" + SET "expiryNotifiedAt" = NOW(), + "updatedAt" = NOW() + WHERE status = ${ListingStatus.ACTIVE}::"ListingStatus" + AND "expiresAt" IS NOT NULL + AND "expiresAt" > ${now} + AND "expiresAt" <= ${in3Days} + AND "expiryNotifiedAt" IS NULL + RETURNING id, "sellerId", "expiresAt" + `; + + if (expiring.length === 0) { + this.logger.debug( + 'No listings expiring in the next 3 days — nothing to notify', + 'ListingExpiryCronService', + ); + return; + } + + for (const row of expiring) { + this.eventBus.publish( + new ListingExpiringEvent(row.id, row.sellerId, row.expiresAt), + ); + } + + this.logger.log( + `Sent expiry-warning events for ${expiring.length} listing(s) at ${now.toISOString()}`, + 'ListingExpiryCronService', + ); + } catch (err) { + this.logger.error( + `Failed to send listing expiry warnings: ${(err as Error).message}`, + (err as Error).stack, + 'ListingExpiryCronService', + ); + } + } +} diff --git a/apps/api/src/modules/listings/listings.module.ts b/apps/api/src/modules/listings/listings.module.ts index fdc46ec..bdb5ad6 100644 --- a/apps/api/src/modules/listings/listings.module.ts +++ b/apps/api/src/modules/listings/listings.module.ts @@ -10,6 +10,7 @@ import { DeleteListingHandler } from './application/commands/delete-listing/dele import { FeatureListingHandler } from './application/commands/feature-listing/feature-listing.handler'; import { ModerateListingHandler } from './application/commands/moderate-listing/moderate-listing.handler'; import { PromoteFeaturedListingHandler } from './application/commands/promote-featured-listing/promote-featured-listing.handler'; +import { ReportListingHandler } from './application/commands/report-listing/report-listing.handler'; import { UpdateListingHandler } from './application/commands/update-listing/update-listing.handler'; import { UpdateListingStatusHandler } from './application/commands/update-listing-status/update-listing-status.handler'; import { UploadMediaHandler } from './application/commands/upload-media/upload-media.handler'; @@ -27,6 +28,7 @@ import { DUPLICATE_DETECTOR } from './domain/services/duplicate-detector'; import { ModerationService } from './domain/services/moderation.service'; import { PRICE_VALIDATOR } from './domain/services/price-validator'; import { FeaturedListingExpiryCronService } from './infrastructure/cron/featured-listing-expiry-cron.service'; +import { ListingExpiryCronService } from './infrastructure/cron/listing-expiry-cron.service'; import { PrismaListingRepository } from './infrastructure/repositories/prisma-listing.repository'; import { PrismaPropertyRepository } from './infrastructure/repositories/prisma-property.repository'; import { MEDIA_STORAGE_SERVICE, MinioMediaStorageService } from './infrastructure/services/media-storage.service'; @@ -45,6 +47,7 @@ const CommandHandlers = [ ModerateListingHandler, DeleteListingHandler, BulkUpdateListingsHandler, + ReportListingHandler, ]; const QueryHandlers = [ @@ -88,6 +91,7 @@ const EventHandlers = [ // Cron FeaturedListingExpiryCronService, + ListingExpiryCronService, // Guards (per-route) FeatureListingThrottlerGuard, diff --git a/apps/api/src/modules/notifications/application/listeners/listing-expiring.listener.ts b/apps/api/src/modules/notifications/application/listeners/listing-expiring.listener.ts new file mode 100644 index 0000000..3eb9c4f --- /dev/null +++ b/apps/api/src/modules/notifications/application/listeners/listing-expiring.listener.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; +import { OnEvent } from '@nestjs/event-emitter'; +import { LoggerService, PrismaService } from '@modules/shared'; +import { ListingExpiringEvent } from '@modules/listings'; +import { SendNotificationCommand } from '../commands/send-notification/send-notification.command'; + +/** + * Handles `listing.expiring` events published by the daily expiry-warning cron. + * + * Sends both an email and a Zalo OA notification to the seller so they can + * renew or extend their listing before it expires. + */ +@Injectable() +export class ListingExpiringListener { + constructor( + private readonly commandBus: CommandBus, + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + @OnEvent('listing.expiring', { async: true }) + async handle(event: ListingExpiringEvent): Promise { + this.logger.log( + `Handling listing.expiring for listing ${event.aggregateId}`, + 'ListingExpiringListener', + ); + + const listing = await this.prisma.listing.findUnique({ + where: { id: event.aggregateId }, + include: { + property: { select: { title: true } }, + seller: { select: { id: true, email: true, phone: true } }, + }, + }); + + if (!listing) return; + + const templateData = { + listingTitle: listing.property.title, + expiresAt: event.expiresAt.toISOString(), + daysRemaining: Math.ceil( + (event.expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24), + ), + }; + + const notifications: Promise[] = []; + + // Email notification + if (listing.seller.email) { + notifications.push( + this.commandBus.execute( + new SendNotificationCommand( + listing.seller.id, + 'EMAIL', + 'listing.expiring', + templateData, + listing.seller.email, + ), + ), + ); + } + + // Zalo OA notification (phone as recipient address) + if (listing.seller.phone) { + notifications.push( + this.commandBus.execute( + new SendNotificationCommand( + listing.seller.id, + 'ZALO_OA', + 'listing.expiring', + templateData, + listing.seller.phone, + ), + ), + ); + } + + await Promise.allSettled(notifications); + } +} diff --git a/apps/api/src/modules/notifications/infrastructure/__tests__/template.service.spec.ts b/apps/api/src/modules/notifications/infrastructure/__tests__/template.service.spec.ts index e422222..86cd34d 100644 --- a/apps/api/src/modules/notifications/infrastructure/__tests__/template.service.spec.ts +++ b/apps/api/src/modules/notifications/infrastructure/__tests__/template.service.spec.ts @@ -81,14 +81,15 @@ describe('TemplateService', () => { expect(result.body).toContain('/listings/2'); }); - it('getTemplateKeys returns all 17 template keys', () => { + it('getTemplateKeys returns all 19 template keys', () => { const keys = service.getTemplateKeys(); - expect(keys).toHaveLength(17); + expect(keys).toHaveLength(19); expect(keys).toContain('user.registered'); expect(keys).toContain('agent.verified'); expect(keys).toContain('listing.approved'); expect(keys).toContain('listing.rejected'); + expect(keys).toContain('listing.expiring'); expect(keys).toContain('inquiry.received'); expect(keys).toContain('quota.exceeded'); expect(keys).toContain('password.reset'); @@ -98,6 +99,7 @@ describe('TemplateService', () => { expect(keys).toContain('saved_search_digest'); expect(keys).toContain('user.email_change_otp'); expect(keys).toContain('user.phone_change_otp'); + expect(keys).toContain('user.phone_login_otp'); expect(keys).toContain('inquiry.reply'); expect(keys).toContain('listing.price_drop'); expect(keys).toContain('subscription.renewal'); diff --git a/apps/api/src/modules/notifications/infrastructure/services/template.service.ts b/apps/api/src/modules/notifications/infrastructure/services/template.service.ts index 60d884c..cb87d1a 100644 --- a/apps/api/src/modules/notifications/infrastructure/services/template.service.ts +++ b/apps/api/src/modules/notifications/infrastructure/services/template.service.ts @@ -30,6 +30,13 @@ const TEMPLATES: Record = { subject: 'Tin đăng đã được duyệt', body: `

Tin đăng được phê duyệt!

Tin đăng {{listingTitle}} của bạn đã được duyệt và hiển thị trên GoodGo.

+

Trân trọng,
Đội ngũ GoodGo

`, + }, + 'listing.expiring': { + subject: 'Tin đăng sắp hết hạn: {{listingTitle}}', + body: `

Tin đăng sắp hết hạn

+

Tin đăng {{listingTitle}} của bạn sẽ hết hạn trong {{daysRemaining}} ngày ({{expiresAt}}).

+

Vui lòng gia hạn tin đăng để tiếp tục hiển thị trên GoodGo.

Trân trọng,
Đội ngũ GoodGo

`, }, 'inquiry.received': { @@ -90,6 +97,10 @@ const TEMPLATES: Record = { subject: 'Xác nhận thay đổi số điện thoại — GoodGo', body: `Mã xác nhận thay đổi số điện thoại GoodGo: {{otpCode}}. Mã có hiệu lực trong 10 phút. Nếu bạn không yêu cầu, hãy bỏ qua tin nhắn này.`, }, + 'user.phone_login_otp': { + subject: 'Mã đăng nhập GoodGo', + body: `Mã đăng nhập GoodGo: {{otpCode}}. Mã có hiệu lực trong 10 phút. Tuyệt đối không chia sẻ mã này với bất kỳ ai.`, + }, 'saved_search_alert': { subject: 'Tin mới phù hợp tìm kiếm "{{searchName}}"', body: `

Xin chào {{userName}}!

diff --git a/apps/api/src/modules/notifications/notifications.module.ts b/apps/api/src/modules/notifications/notifications.module.ts index 0de4e12..dc173e1 100644 --- a/apps/api/src/modules/notifications/notifications.module.ts +++ b/apps/api/src/modules/notifications/notifications.module.ts @@ -7,6 +7,7 @@ import { AgentVerifiedListener } from './application/listeners/agent-verified.li import { EmailChangeRequestedListener } from './application/listeners/email-change-requested.listener'; import { InquiryReceivedListener } from './application/listeners/inquiry-received.listener'; import { ListingApprovedListener } from './application/listeners/listing-approved.listener'; +import { ListingExpiringListener } from './application/listeners/listing-expiring.listener'; import { ListingRejectedListener } from './application/listeners/listing-rejected.listener'; import { ListingSoldListener } from './application/listeners/listing-sold.listener'; import { PasswordResetRequestedListener } from './application/listeners/password-reset-requested.listener'; @@ -14,6 +15,7 @@ import { PaymentCompletedListener } from './application/listeners/payment-comple import { PaymentFailedListener } from './application/listeners/payment-failed.listener'; import { PaymentRefundedListener } from './application/listeners/payment-refunded.listener'; import { PhoneChangeRequestedListener } from './application/listeners/phone-change-requested.listener'; +import { PhoneLoginOtpRequestedListener } from './application/listeners/phone-login-otp-requested.listener'; import { QuotaExceededListener } from './application/listeners/quota-exceeded.listener'; import { ResidentialInquiryReplyListener, @@ -48,6 +50,7 @@ const EventListeners = [ AgentVerifiedListener, QuotaExceededListener, ListingApprovedListener, + ListingExpiringListener, ListingRejectedListener, PaymentCompletedListener, PaymentFailedListener, @@ -60,6 +63,7 @@ const EventListeners = [ UserKycUpdatedListener, EmailChangeRequestedListener, PhoneChangeRequestedListener, + PhoneLoginOtpRequestedListener, PasswordResetRequestedListener, ResidentialPriceDropListener, ResidentialNewListingInProjectListener, diff --git a/prisma/migrations/20260423100000_add_listing_expiry_notified_at/migration.sql b/prisma/migrations/20260423100000_add_listing_expiry_notified_at/migration.sql new file mode 100644 index 0000000..bfaf420 --- /dev/null +++ b/prisma/migrations/20260423100000_add_listing_expiry_notified_at/migration.sql @@ -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); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e022615..64a1c0b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +}