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:
@@ -3,5 +3,6 @@ export { ListingApprovedEvent } from './listing-approved.event';
|
|||||||
export { ListingPriceChangedEvent } from './listing-price-changed.event';
|
export { ListingPriceChangedEvent } from './listing-price-changed.event';
|
||||||
export { ListingSoldEvent } from './listing-sold.event';
|
export { ListingSoldEvent } from './listing-sold.event';
|
||||||
export { ListingFeaturedExpiredEvent } from './listing-featured-expired.event';
|
export { ListingFeaturedExpiredEvent } from './listing-featured-expired.event';
|
||||||
|
export { ListingExpiringEvent } from './listing-expiring.event';
|
||||||
export { ListingOwnershipTransferredEvent } from './listing-ownership-transferred.event';
|
export { ListingOwnershipTransferredEvent } from './listing-ownership-transferred.event';
|
||||||
export { FeaturedListingPaymentRequestedEvent } from './featured-listing-payment-requested.event';
|
export { FeaturedListingPaymentRequestedEvent } from './featured-listing-payment-requested.event';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -21,4 +21,5 @@ export { ListingSoldEvent } from './domain/events/listing-sold.event';
|
|||||||
export { ListingStatusChangedEvent } from './domain/events/listing-status-changed.event';
|
export { ListingStatusChangedEvent } from './domain/events/listing-status-changed.event';
|
||||||
export { ListingOwnershipTransferredEvent } from './domain/events/listing-ownership-transferred.event';
|
export { ListingOwnershipTransferredEvent } from './domain/events/listing-ownership-transferred.event';
|
||||||
export { ListingFeaturedExpiredEvent } from './domain/events/listing-featured-expired.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';
|
export { Price } from './domain/value-objects/price.vo';
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { DeleteListingHandler } from './application/commands/delete-listing/dele
|
|||||||
import { FeatureListingHandler } from './application/commands/feature-listing/feature-listing.handler';
|
import { FeatureListingHandler } from './application/commands/feature-listing/feature-listing.handler';
|
||||||
import { ModerateListingHandler } from './application/commands/moderate-listing/moderate-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 { 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 { UpdateListingHandler } from './application/commands/update-listing/update-listing.handler';
|
||||||
import { UpdateListingStatusHandler } from './application/commands/update-listing-status/update-listing-status.handler';
|
import { UpdateListingStatusHandler } from './application/commands/update-listing-status/update-listing-status.handler';
|
||||||
import { UploadMediaHandler } from './application/commands/upload-media/upload-media.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 { ModerationService } from './domain/services/moderation.service';
|
||||||
import { PRICE_VALIDATOR } from './domain/services/price-validator';
|
import { PRICE_VALIDATOR } from './domain/services/price-validator';
|
||||||
import { FeaturedListingExpiryCronService } from './infrastructure/cron/featured-listing-expiry-cron.service';
|
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 { PrismaListingRepository } from './infrastructure/repositories/prisma-listing.repository';
|
||||||
import { PrismaPropertyRepository } from './infrastructure/repositories/prisma-property.repository';
|
import { PrismaPropertyRepository } from './infrastructure/repositories/prisma-property.repository';
|
||||||
import { MEDIA_STORAGE_SERVICE, MinioMediaStorageService } from './infrastructure/services/media-storage.service';
|
import { MEDIA_STORAGE_SERVICE, MinioMediaStorageService } from './infrastructure/services/media-storage.service';
|
||||||
@@ -45,6 +47,7 @@ const CommandHandlers = [
|
|||||||
ModerateListingHandler,
|
ModerateListingHandler,
|
||||||
DeleteListingHandler,
|
DeleteListingHandler,
|
||||||
BulkUpdateListingsHandler,
|
BulkUpdateListingsHandler,
|
||||||
|
ReportListingHandler,
|
||||||
];
|
];
|
||||||
|
|
||||||
const QueryHandlers = [
|
const QueryHandlers = [
|
||||||
@@ -88,6 +91,7 @@ const EventHandlers = [
|
|||||||
|
|
||||||
// Cron
|
// Cron
|
||||||
FeaturedListingExpiryCronService,
|
FeaturedListingExpiryCronService,
|
||||||
|
ListingExpiryCronService,
|
||||||
|
|
||||||
// Guards (per-route)
|
// Guards (per-route)
|
||||||
FeatureListingThrottlerGuard,
|
FeatureListingThrottlerGuard,
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<unknown>[] = [];
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,14 +81,15 @@ describe('TemplateService', () => {
|
|||||||
expect(result.body).toContain('/listings/2');
|
expect(result.body).toContain('/listings/2');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getTemplateKeys returns all 17 template keys', () => {
|
it('getTemplateKeys returns all 19 template keys', () => {
|
||||||
const keys = service.getTemplateKeys();
|
const keys = service.getTemplateKeys();
|
||||||
|
|
||||||
expect(keys).toHaveLength(17);
|
expect(keys).toHaveLength(19);
|
||||||
expect(keys).toContain('user.registered');
|
expect(keys).toContain('user.registered');
|
||||||
expect(keys).toContain('agent.verified');
|
expect(keys).toContain('agent.verified');
|
||||||
expect(keys).toContain('listing.approved');
|
expect(keys).toContain('listing.approved');
|
||||||
expect(keys).toContain('listing.rejected');
|
expect(keys).toContain('listing.rejected');
|
||||||
|
expect(keys).toContain('listing.expiring');
|
||||||
expect(keys).toContain('inquiry.received');
|
expect(keys).toContain('inquiry.received');
|
||||||
expect(keys).toContain('quota.exceeded');
|
expect(keys).toContain('quota.exceeded');
|
||||||
expect(keys).toContain('password.reset');
|
expect(keys).toContain('password.reset');
|
||||||
@@ -98,6 +99,7 @@ describe('TemplateService', () => {
|
|||||||
expect(keys).toContain('saved_search_digest');
|
expect(keys).toContain('saved_search_digest');
|
||||||
expect(keys).toContain('user.email_change_otp');
|
expect(keys).toContain('user.email_change_otp');
|
||||||
expect(keys).toContain('user.phone_change_otp');
|
expect(keys).toContain('user.phone_change_otp');
|
||||||
|
expect(keys).toContain('user.phone_login_otp');
|
||||||
expect(keys).toContain('inquiry.reply');
|
expect(keys).toContain('inquiry.reply');
|
||||||
expect(keys).toContain('listing.price_drop');
|
expect(keys).toContain('listing.price_drop');
|
||||||
expect(keys).toContain('subscription.renewal');
|
expect(keys).toContain('subscription.renewal');
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ const TEMPLATES: Record<string, TemplateDefinition> = {
|
|||||||
subject: 'Tin đăng đã được duyệt',
|
subject: 'Tin đăng đã được duyệt',
|
||||||
body: `<h1>Tin đăng được phê duyệt!</h1>
|
body: `<h1>Tin đăng được phê duyệt!</h1>
|
||||||
<p>Tin đăng <strong>{{listingTitle}}</strong> của bạn đã được duyệt và hiển thị trên GoodGo.</p>
|
<p>Tin đăng <strong>{{listingTitle}}</strong> của bạn đã được duyệt và hiển thị trên GoodGo.</p>
|
||||||
|
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
|
||||||
|
},
|
||||||
|
'listing.expiring': {
|
||||||
|
subject: 'Tin đăng sắp hết hạn: {{listingTitle}}',
|
||||||
|
body: `<h1>Tin đăng sắp hết hạn</h1>
|
||||||
|
<p>Tin đăng <strong>{{listingTitle}}</strong> của bạn sẽ hết hạn trong <strong>{{daysRemaining}} ngày</strong> ({{expiresAt}}).</p>
|
||||||
|
<p>Vui lòng gia hạn tin đăng để tiếp tục hiển thị trên GoodGo.</p>
|
||||||
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
|
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
|
||||||
},
|
},
|
||||||
'inquiry.received': {
|
'inquiry.received': {
|
||||||
@@ -90,6 +97,10 @@ const TEMPLATES: Record<string, TemplateDefinition> = {
|
|||||||
subject: 'Xác nhận thay đổi số điện thoại — GoodGo',
|
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.`,
|
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': {
|
'saved_search_alert': {
|
||||||
subject: 'Tin mới phù hợp tìm kiếm "{{searchName}}"',
|
subject: 'Tin mới phù hợp tìm kiếm "{{searchName}}"',
|
||||||
body: `<h1>Xin chào {{userName}}!</h1>
|
body: `<h1>Xin chào {{userName}}!</h1>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { AgentVerifiedListener } from './application/listeners/agent-verified.li
|
|||||||
import { EmailChangeRequestedListener } from './application/listeners/email-change-requested.listener';
|
import { EmailChangeRequestedListener } from './application/listeners/email-change-requested.listener';
|
||||||
import { InquiryReceivedListener } from './application/listeners/inquiry-received.listener';
|
import { InquiryReceivedListener } from './application/listeners/inquiry-received.listener';
|
||||||
import { ListingApprovedListener } from './application/listeners/listing-approved.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 { ListingRejectedListener } from './application/listeners/listing-rejected.listener';
|
||||||
import { ListingSoldListener } from './application/listeners/listing-sold.listener';
|
import { ListingSoldListener } from './application/listeners/listing-sold.listener';
|
||||||
import { PasswordResetRequestedListener } from './application/listeners/password-reset-requested.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 { PaymentFailedListener } from './application/listeners/payment-failed.listener';
|
||||||
import { PaymentRefundedListener } from './application/listeners/payment-refunded.listener';
|
import { PaymentRefundedListener } from './application/listeners/payment-refunded.listener';
|
||||||
import { PhoneChangeRequestedListener } from './application/listeners/phone-change-requested.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 { QuotaExceededListener } from './application/listeners/quota-exceeded.listener';
|
||||||
import {
|
import {
|
||||||
ResidentialInquiryReplyListener,
|
ResidentialInquiryReplyListener,
|
||||||
@@ -48,6 +50,7 @@ const EventListeners = [
|
|||||||
AgentVerifiedListener,
|
AgentVerifiedListener,
|
||||||
QuotaExceededListener,
|
QuotaExceededListener,
|
||||||
ListingApprovedListener,
|
ListingApprovedListener,
|
||||||
|
ListingExpiringListener,
|
||||||
ListingRejectedListener,
|
ListingRejectedListener,
|
||||||
PaymentCompletedListener,
|
PaymentCompletedListener,
|
||||||
PaymentFailedListener,
|
PaymentFailedListener,
|
||||||
@@ -60,6 +63,7 @@ const EventListeners = [
|
|||||||
UserKycUpdatedListener,
|
UserKycUpdatedListener,
|
||||||
EmailChangeRequestedListener,
|
EmailChangeRequestedListener,
|
||||||
PhoneChangeRequestedListener,
|
PhoneChangeRequestedListener,
|
||||||
|
PhoneLoginOtpRequestedListener,
|
||||||
PasswordResetRequestedListener,
|
PasswordResetRequestedListener,
|
||||||
ResidentialPriceDropListener,
|
ResidentialPriceDropListener,
|
||||||
ResidentialNewListingInProjectListener,
|
ResidentialNewListingInProjectListener,
|
||||||
|
|||||||
@@ -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).
|
/// KCN do user này vận hành (role=PARK_OPERATOR).
|
||||||
ownedIndustrialParks IndustrialPark[] @relation("IndustrialParkOwner")
|
ownedIndustrialParks IndustrialPark[] @relation("IndustrialParkOwner")
|
||||||
zaloAccountLink ZaloAccountLink?
|
zaloAccountLink ZaloAccountLink?
|
||||||
|
notificationLogs NotificationLog[]
|
||||||
|
industrialListingsSelling IndustrialListing[] @relation("IndustrialListingSeller")
|
||||||
|
listingFlagsReported ListingFlag[] @relation("listingFlagsReported")
|
||||||
|
|
||||||
@@index([role])
|
@@index([role])
|
||||||
@@index([kycStatus])
|
@@index([kycStatus])
|
||||||
@@ -187,6 +190,7 @@ model Agent {
|
|||||||
|
|
||||||
listings Listing[]
|
listings Listing[]
|
||||||
leads Lead[]
|
leads Lead[]
|
||||||
|
industrialListings IndustrialListing[] @relation("IndustrialListingAgent")
|
||||||
|
|
||||||
@@index([qualityScore])
|
@@index([qualityScore])
|
||||||
@@index([isVerified])
|
@@index([isVerified])
|
||||||
@@ -310,6 +314,15 @@ enum PropertyCondition {
|
|||||||
USED
|
USED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum LegalStatus {
|
||||||
|
SO_DO
|
||||||
|
SO_HONG
|
||||||
|
LAND_USE_RIGHT
|
||||||
|
JOINT_USE_RIGHT
|
||||||
|
AWAITING
|
||||||
|
NO_CERTIFICATE
|
||||||
|
}
|
||||||
|
|
||||||
model Property {
|
model Property {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
propertyType PropertyType
|
propertyType PropertyType
|
||||||
@@ -333,7 +346,8 @@ model Property {
|
|||||||
totalFloors Int?
|
totalFloors Int?
|
||||||
direction Direction?
|
direction Direction?
|
||||||
yearBuilt Int?
|
yearBuilt Int?
|
||||||
legalStatus String?
|
legalStatus LegalStatus?
|
||||||
|
certificateVerified Boolean @default(false)
|
||||||
amenities Json?
|
amenities Json?
|
||||||
nearbyPOIs Json?
|
nearbyPOIs Json?
|
||||||
metroDistanceM Float?
|
metroDistanceM Float?
|
||||||
@@ -411,8 +425,9 @@ model Listing {
|
|||||||
inquiryCount Int @default(0)
|
inquiryCount Int @default(0)
|
||||||
featuredUntil DateTime?
|
featuredUntil DateTime?
|
||||||
featuredPackage String? /// "3_days" | "7_days" | "30_days"
|
featuredPackage String? /// "3_days" | "7_days" | "30_days"
|
||||||
expiresAt DateTime?
|
expiresAt DateTime?
|
||||||
publishedAt DateTime?
|
expiryNotifiedAt DateTime?
|
||||||
|
publishedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -421,6 +436,8 @@ model Listing {
|
|||||||
orders Order[]
|
orders Order[]
|
||||||
priceHistories PriceHistory[]
|
priceHistories PriceHistory[]
|
||||||
savedByUsers SavedListing[]
|
savedByUsers SavedListing[]
|
||||||
|
conversations Conversation[]
|
||||||
|
flags ListingFlag[]
|
||||||
|
|
||||||
// --- Single-column indexes ---
|
// --- Single-column indexes ---
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@ -456,6 +473,45 @@ model PriceHistory {
|
|||||||
@@index([listingId, changedAt(sort: Desc)])
|
@@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
|
// SEARCH
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -824,6 +880,7 @@ enum NotificationStatus {
|
|||||||
model NotificationLog {
|
model NotificationLog {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
channel NotificationChannel
|
channel NotificationChannel
|
||||||
templateKey String
|
templateKey String
|
||||||
subject String?
|
subject String?
|
||||||
@@ -1110,7 +1167,9 @@ model IndustrialListing {
|
|||||||
parkId String
|
parkId String
|
||||||
park IndustrialPark @relation(fields: [parkId], references: [id], onDelete: Cascade)
|
park IndustrialPark @relation(fields: [parkId], references: [id], onDelete: Cascade)
|
||||||
agentId String?
|
agentId String?
|
||||||
|
agent Agent? @relation("IndustrialListingAgent", fields: [agentId], references: [id], onDelete: SetNull)
|
||||||
sellerId String
|
sellerId String
|
||||||
|
seller User @relation("IndustrialListingSeller", fields: [sellerId], references: [id], onDelete: Restrict)
|
||||||
propertyType IndustrialPropertyType
|
propertyType IndustrialPropertyType
|
||||||
leaseType IndustrialLeaseType
|
leaseType IndustrialLeaseType
|
||||||
status IndustrialListingStatus @default(DRAFT)
|
status IndustrialListingStatus @default(DRAFT)
|
||||||
@@ -1170,6 +1229,7 @@ enum ConversationStatus {
|
|||||||
model Conversation {
|
model Conversation {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
listingId String?
|
listingId String?
|
||||||
|
listing Listing? @relation(fields: [listingId], references: [id], onDelete: SetNull)
|
||||||
subject String?
|
subject String?
|
||||||
status ConversationStatus @default(ACTIVE)
|
status ConversationStatus @default(ACTIVE)
|
||||||
lastMessage String? @db.Text
|
lastMessage String? @db.Text
|
||||||
@@ -1438,3 +1498,72 @@ model SystemSetting {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
updatedBy String?
|
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