fix: ( notification ) add notification templet and fix issues

This commit is contained in:
Abubeker Yasin
2026-06-19 06:12:46 +03:00
parent f1dfbbcc3f
commit 42c020c97a
6 changed files with 75 additions and 31 deletions

View File

@@ -141,6 +141,9 @@ ALTER TABLE "SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0;
ALTER TABLE "Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE'; ALTER TABLE "Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE';
-- AlterTable -- AlterTable
-- gender is created here on a clean migration history (no prior migration adds it);
-- on an already-drifted DB where it exists as varchar, normalize it to TEXT.
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "gender" TEXT;
ALTER TABLE "User" ALTER COLUMN "gender" SET DATA TYPE TEXT; ALTER TABLE "User" ALTER COLUMN "gender" SET DATA TYPE TEXT;
-- CreateIndex -- CreateIndex

View File

@@ -550,18 +550,22 @@ async function seedSegmentFares() {
async function seedNotificationTemplates() { async function seedNotificationTemplates() {
console.log('\n🔔 Seeding notification templates...'); console.log('\n🔔 Seeding notification templates...');
// NOTE: `code` must match the templateKey passed by NotificationsService.send(...).
// The event-driven handlers use the dotted event names (booking.created, payment.succeeded).
const templates = [ const templates = [
{ id: uuidv4(), code: 'BOOKING_CONFIRMED', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed for {{date}}' }, { id: uuidv4(), code: 'booking.created', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed. Total: {{amount}} {{currency}}.' },
{ id: uuidv4(), code: 'PAYMENT_RECEIVED', channel: 'SMS', bodyTemplate: 'Payment ETB {{amount}} received for {{bookingRef}}' }, { id: uuidv4(), code: 'payment.succeeded', channel: 'SMS', subject: 'Payment Received', bodyTemplate: 'Payment of {{amount}} {{currency}} received for booking {{bookingRef}}.' },
{ id: uuidv4(), code: 'TRIP_DEPARTURE', channel: 'PUSH', bodyTemplate: 'Your trip {{route}} departs in {{minutes}} minutes' }, // Templates below are not wired to handlers yet (Phase 2 — full event coverage).
{ id: uuidv4(), code: 'TRIP_DELAY', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip {{route}} is delayed by {{delayMinutes}} minutes' }, { id: uuidv4(), code: 'trip.departure', channel: 'PUSH', subject: 'Trip Departing Soon', bodyTemplate: 'Your trip {{route}} departs in {{minutes}} minutes' },
{ id: uuidv4(), code: 'PROMOTION', channel: 'PUSH', bodyTemplate: 'Get {{percentOff}}% off on {{route}}' }, { id: uuidv4(), code: 'trip.delay', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip {{route}} is delayed by {{delayMinutes}} minutes' },
{ id: uuidv4(), code: 'promotion.offer', channel: 'PUSH', subject: 'Special Offer', bodyTemplate: 'Get {{percentOff}}% off on {{route}}' },
]; ];
for (const t of templates) { for (const t of templates) {
await prisma.notificationTemplate.upsert({ await prisma.notificationTemplate.upsert({
where: { code: t.code }, where: { code: t.code },
update: {}, // Refresh the editable fields on re-seed so template tweaks actually take effect.
update: { channel: t.channel, subject: t.subject ?? null, bodyTemplate: t.bodyTemplate, active: true },
create: t, create: t,
}); });
} }
@@ -684,7 +688,8 @@ async function main() {
['system users', seedSystemUsers], ['system users', seedSystemUsers],
['fare rules', seedFareRules], ['fare rules', seedFareRules],
['segment fares', seedSegmentFares], ['segment fares', seedSegmentFares],
['currency', seedCurrency] ['currency', seedCurrency],
['notification templates', seedNotificationTemplates]
]; ];
let failed = 0; let failed = 0;

View File

@@ -40,18 +40,5 @@ export class SendEmail {
@IsOptional() @IsOptional()
context?: Record<string, any>; context?: Record<string, any>;
@ApiPropertyOptional()
@IsOptional()
@IsString()
templateName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsEmail()
from?: string;
@ApiPropertyOptional()
@IsOptional()
@IsEmail()
replyTo?: string;
} }

View File

@@ -29,11 +29,17 @@ export class EmailClientService implements OnApplicationBootstrap {
} }
async sendEmail(dto: SendEmail) { async sendEmail(dto: SendEmail) {
if (!this.enabled) return {}; if (!this.enabled) {
this.logger.warn(`RABBITMQ disabled — skipped EMAIL to ${dto.to}`);
return {};
}
this.emailServiceClient.emit("send-email", { this.emailServiceClient.emit("send-email", {
...dto, ...dto,
appKey: "IFHCRS-LICENSE-MANAGEMENT", appKey: "IFHCRS-LICENSE-MANAGEMENT",
}); });
this.logger.log(
`EMAIL emitted to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email' to=${dto.to} subject="${dto.subject ?? ""}" body="${dto.text ?? dto.body ?? dto.html ?? ""}"`,
);
return {}; return {};
} }
} }

View File

@@ -169,7 +169,12 @@ export class NotificationsService {
private async getUserPreferredChannels(recipient: string): Promise<NotificationChannelType[]> { private async getUserPreferredChannels(recipient: string): Promise<NotificationChannelType[]> {
const user = await this.prisma.user.findFirst({ const user = await this.prisma.user.findFirst({
where: { where: {
OR: [{ id: recipient }, { email: recipient }, { phone: recipient }], OR: [
{ id: recipient },
{ email: recipient },
{ phone: recipient },
{ passenger: { id: recipient } },
],
}, },
include: { preferences: true }, include: { preferences: true },
}); });
@@ -192,7 +197,12 @@ export class NotificationsService {
): Promise<string | null> { ): Promise<string | null> {
const user = await this.prisma.user.findFirst({ const user = await this.prisma.user.findFirst({
where: { where: {
OR: [{ id: recipient }, { email: recipient }, { phone: recipient }], OR: [
{ id: recipient },
{ email: recipient },
{ phone: recipient },
{ passenger: { id: recipient } },
],
}, },
}); });
@@ -239,27 +249,46 @@ export class NotificationsService {
@OnEvent('booking.created') @OnEvent('booking.created')
async onBookingCreated(payload: any) { async onBookingCreated(payload: any) {
const booking = payload.booking;
await this.send( await this.send(
'booking.created', 'booking.created',
payload.booking.passengerId, booking.passengerId,
{ {
bookingRef: payload.booking.bookingRef, bookingRef: booking.bookingRef,
amount: this.formatAmount(booking),
currency: booking.displayCurrency ?? 'ETB',
category: 'BOOKING', category: 'BOOKING',
deepLink: `edr://bookings/${payload.booking.bookingRef}`, deepLink: `edr://bookings/${booking.bookingRef}`,
}, },
// For now, always notify the travelling passenger on every channel.
['IN_APP', 'EMAIL', 'SMS'],
); );
} }
@OnEvent('payment.succeeded') @OnEvent('payment.succeeded')
async onPaymentSucceeded(payload: any) { async onPaymentSucceeded(payload: any) {
const booking = payload.booking;
await this.send( await this.send(
'payment.succeeded', 'payment.succeeded',
payload.booking.passengerId, booking.passengerId,
{ {
bookingRef: payload.booking.bookingRef, bookingRef: booking.bookingRef,
amount: this.formatAmount(booking),
currency: booking.displayCurrency ?? 'ETB',
category: 'PAYMENT', category: 'PAYMENT',
deepLink: `edr://tickets/${payload.booking.bookingRef}`, deepLink: `edr://tickets/${booking.bookingRef}`,
}, },
['IN_APP', 'EMAIL', 'SMS'],
); );
} }
/**
* Formats a booking's payable amount from minor units into a major-unit string.
* Money is stored as integer minor units (e.g. 59600 santim) to avoid floating-point
* drift; we divide by 100 only here, at the display edge. e.g. 59600 -> "596.00".
*/
private formatAmount(booking: any): string {
const minor = booking.displayTotalMinor ?? booking.totalMinor ?? 0;
return (minor / 100).toFixed(2);
}
} }

View File

@@ -31,20 +31,34 @@ export class SmsClientService implements OnApplicationBootstrap {
} }
async sendSms(dto: SingleMessageDto) { async sendSms(dto: SingleMessageDto) {
if (!this.enabled) return {}; if (!this.enabled) {
this.logger.warn(`RABBITMQ disabled — skipped SMS to ${dto.to}`);
return {};
}
this.smsClient.emit("send-sms", { this.smsClient.emit("send-sms", {
...dto, ...dto,
appKey: "IFHCRS-LICENSE-MANAGEMENT", appKey: "IFHCRS-LICENSE-MANAGEMENT",
}); });
this.logger.log(
`SMS emitted to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms' to=${dto.to} message="${dto.message}"`,
);
return {}; return {};
} }
async sendBulkMessages(dto: BulkMessagesDto) { async sendBulkMessages(dto: BulkMessagesDto) {
if (!this.enabled) return {}; if (!this.enabled) {
this.logger.warn(`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`);
return {};
}
this.smsClient.emit("ozeking-bulk-sms", { this.smsClient.emit("ozeking-bulk-sms", {
...dto, ...dto,
appKey: "IFHCRS-LICENSE-MANAGEMENT", appKey: "IFHCRS-LICENSE-MANAGEMENT",
}); });
this.logger.log(
`BULK SMS emitted to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${dto.messages?.length ?? 0} messages=${JSON.stringify(
(dto.messages ?? []).map((m) => ({ to: m.to, message: m.message })),
)}`,
);
return {}; return {};
} }
} }