From 93b509b2567c3b41325c78343ad01fd337098c9d Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Tue, 6 Jan 2026 12:38:19 +0300 Subject: [PATCH] feat: add notification event handling and notification factory service - Introduce constants for notification event names - Implement interfaces for transaction created events - Create a transaction notification listener to handle transaction notifications - Develop a notification factory service for sending notifications based on user preferences - Add a migration to include a data column in the notifications table --- .../constants/event-names.constant.ts | 15 ++ .../modules/notification/constants/index.ts | 3 + .../notification-events.interface.ts | 24 ++ .../transaction-notification.listener.ts | 206 ++++++++++++++++++ .../services/notification-factory.service.ts | 160 ++++++++++++++ ...7172707881-AddDataColumnToNotifications.ts | 16 ++ 6 files changed, 424 insertions(+) create mode 100644 src/common/modules/notification/constants/event-names.constant.ts create mode 100644 src/common/modules/notification/constants/index.ts create mode 100644 src/common/modules/notification/interfaces/notification-events.interface.ts create mode 100644 src/common/modules/notification/listeners/transaction-notification.listener.ts create mode 100644 src/common/modules/notification/services/notification-factory.service.ts create mode 100644 src/db/migrations/1767172707881-AddDataColumnToNotifications.ts diff --git a/src/common/modules/notification/constants/event-names.constant.ts b/src/common/modules/notification/constants/event-names.constant.ts new file mode 100644 index 0000000..681763d --- /dev/null +++ b/src/common/modules/notification/constants/event-names.constant.ts @@ -0,0 +1,15 @@ +/** + * Notification event names + * These are the event identifiers used throughout the notification system + */ +export const NOTIFICATION_EVENTS = { + // Transaction events + TRANSACTION_CREATED: 'notification.transaction.created', +} as const; + +export type NotificationEventName = + typeof NOTIFICATION_EVENTS[keyof typeof NOTIFICATION_EVENTS]; + + + + diff --git a/src/common/modules/notification/constants/index.ts b/src/common/modules/notification/constants/index.ts new file mode 100644 index 0000000..e0de420 --- /dev/null +++ b/src/common/modules/notification/constants/index.ts @@ -0,0 +1,3 @@ +// Export all constants from this folder +export * from './event-names.constant'; + diff --git a/src/common/modules/notification/interfaces/notification-events.interface.ts b/src/common/modules/notification/interfaces/notification-events.interface.ts new file mode 100644 index 0000000..953da85 --- /dev/null +++ b/src/common/modules/notification/interfaces/notification-events.interface.ts @@ -0,0 +1,24 @@ +import { Transaction } from '~/card/entities/transaction.entity'; +import { Card } from '~/card/entities/card.entity'; + +/** + * Event payload for when a transaction is created + * Used to notify users about transactions (spending or top-ups) + */ +export interface ITransactionCreatedEvent { + /** The transaction that was created */ + transaction: Transaction; + + /** The card used in the transaction (with all relations loaded) */ + card: Card; + + /** True if this is a top-up/load transaction, false if spending */ + isTopUp: boolean; + + /** True if this transaction was made by a child (requires parent notification) */ + isChildSpending: boolean; + + /** When the event occurred */ + timestamp: Date; +} + diff --git a/src/common/modules/notification/listeners/transaction-notification.listener.ts b/src/common/modules/notification/listeners/transaction-notification.listener.ts new file mode 100644 index 0000000..c57d9c8 --- /dev/null +++ b/src/common/modules/notification/listeners/transaction-notification.listener.ts @@ -0,0 +1,206 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { NotificationFactory, NotificationPreferences } from '../services/notification-factory.service'; +import { UserService } from '~/user/services/user.service'; +import { NOTIFICATION_EVENTS } from '../constants/event-names.constant'; +import { ITransactionCreatedEvent } from '../interfaces/notification-events.interface'; +import { NotificationScope } from '../enums/notification-scope.enum'; +import { Transaction } from '~/card/entities/transaction.entity'; +import { Card } from '~/card/entities/card.entity'; +import { User } from '~/user/entities'; + +/** + * TransactionNotificationListener + * + * Handles notifications for transaction events. + * Determines who should be notified and what message to send. + * + * Responsibilities: + * - Listen for transaction events + * - Determine notification recipients (child, parent, or both) + * - Construct appropriate messages + * - Fetch user preferences + * - Call NotificationFactory to send + */ +@Injectable() +export class TransactionNotificationListener { + private readonly logger = new Logger(TransactionNotificationListener.name); + + constructor( + private readonly notificationFactory: NotificationFactory, + private readonly userService: UserService, + ) {} + + /** + * Main event handler for transaction created events + * Routes to appropriate notification logic based on transaction type + */ + @OnEvent(NOTIFICATION_EVENTS.TRANSACTION_CREATED) + async handleTransactionCreated(event: ITransactionCreatedEvent): Promise { + try { + const { transaction, card, isTopUp, isChildSpending } = event; + + this.logger.log( + `Processing transaction notification for transaction ${transaction.id} - ` + + `isTopUp: ${isTopUp}, isChildSpending: ${isChildSpending}` + ); + + // Notify the transaction owner (child or parent) + await this.notifyTransactionOwner(transaction, card, isTopUp, isChildSpending); + + // If child spending, also notify parent + if (isChildSpending && !isTopUp) { + await this.notifyParentOfChildSpending(transaction, card); + } + + this.logger.log( + `Transaction notification processed successfully for transaction ${transaction.id}` + ); + } catch (error: any) { + this.logger.error( + `Failed to process transaction notification: ${error?.message || 'Unknown error'}`, + error?.stack + ); + // Don't throw - notification failures should not break the main flow + } + } + + /** + * Notify the transaction owner (the cardholder) + * Could be a child or a parent depending on whose card was used + */ + private async notifyTransactionOwner( + transaction: Transaction, + card: Card, + isTopUp: boolean, + isChildSpending: boolean + ): Promise { + try { + // Extract user from card + const user = card?.customer?.user; + if (!user) { + this.logger.warn(`No user found for transaction ${transaction.id}, skipping notification`); + return; + } + + // Determine the scope based on transaction type + const scope = isTopUp + ? NotificationScope.CHILD_TOP_UP + : NotificationScope.CHILD_SPENDING; + + // Construct title + const title = isTopUp ? '💰 Card Topped Up' : '💳 Purchase Successful'; + + // Extract data + const amount = transaction.transactionAmount; + const merchant = transaction.merchantName || 'merchant'; + const balance = card.account?.balance || 0; + + // Construct message + const message = isTopUp + ? `Your card has been topped up with $${amount.toFixed(2)}` + : `You spent $${amount.toFixed(2)} at ${merchant}. Balance: $${balance.toFixed(2)}`; + + this.logger.debug( + `Notifying transaction owner (user ${user.id}) - Amount: $${amount}, Merchant: ${merchant}` + ); + + // Send notification + await this.notificationFactory.send({ + userId: user.id, + title, + message, + scope, + preferences: this.getUserPreferences(user), + data: { + transactionId: transaction.id, + amount: amount.toString(), + merchant: merchant, + merchantCategory: transaction.merchantCategoryCode || 'OTHER', + balance: balance.toString(), + timestamp: transaction.transactionDate.toISOString(), + type: isTopUp ? 'TOP_UP' : 'SPENDING', + action: 'OPEN_TRANSACTION', + }, + }); + + this.logger.log(`✅ Notified user ${user.id} for transaction ${transaction.id}`); + } catch (error: any) { + this.logger.error( + `Failed to notify transaction owner: ${error?.message || 'Unknown error'}`, + error?.stack + ); + } + } + + /** + * Notify parent when their child makes a purchase + * This is a spending alert for parents to monitor their children's expenses + */ + private async notifyParentOfChildSpending(transaction: Transaction, card: Card): Promise { + try { + this.logger.debug(`Checking for parent to notify about child spending`); + + // Check if child has guardian + const customer = card?.customer; + const parentUser = customer?.junior?.guardian?.customer?.user; + + if (!parentUser) { + this.logger.debug(`No parent found for transaction ${transaction.id}, skipping parent notification`); + return; + } + + // Get child info + const childUser = customer.user; + const childName = childUser?.firstName || 'Your child'; + const amount = transaction.transactionAmount; + const merchant = transaction.merchantName || 'a merchant'; + + this.logger.debug( + `Notifying parent (user ${parentUser.id}): ${childName} spent $${amount} at ${merchant}` + ); + + // Send notification to parent + await this.notificationFactory.send({ + userId: parentUser.id, + title: '🚨 Child Spending Alert', + message: `${childName} spent $${amount.toFixed(2)} at ${merchant}`, + scope: NotificationScope.PARENT_SPENDING_ALERT, + preferences: this.getUserPreferences(parentUser), + data: { + transactionId: transaction.id, + childId: childUser.id, + childName: childName, + amount: amount.toString(), + merchant: merchant, + merchantCategory: transaction.merchantCategoryCode || 'OTHER', + timestamp: transaction.transactionDate.toISOString(), + type: 'CHILD_SPENDING', + action: 'OPEN_TRANSACTION', + }, + }); + + this.logger.log(`✅ Notified parent ${parentUser.id} about child spending`); + } catch (error: any) { + this.logger.error( + `Failed to notify parent of child spending: ${error?.message || 'Unknown error'}`, + error?.stack + ); + // Don't throw - parent notification failure should not break child notification + } + } + + /** + * Extract user preferences from User entity + * Converts User properties to NotificationPreferences interface + */ + private getUserPreferences(user: User): NotificationPreferences { + return { + isPushEnabled: user.isPushEnabled, + isEmailEnabled: user.isEmailEnabled, + isSmsEnabled: user.isSmsEnabled, + }; + } +} + + diff --git a/src/common/modules/notification/services/notification-factory.service.ts b/src/common/modules/notification/services/notification-factory.service.ts new file mode 100644 index 0000000..dcda0fe --- /dev/null +++ b/src/common/modules/notification/services/notification-factory.service.ts @@ -0,0 +1,160 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { NotificationsService } from './notifications.service'; +import { NotificationChannel } from '../enums/notification-channel.enum'; +import { NotificationScope } from '../enums/notification-scope.enum'; + +/** + * User notification preferences + * Determines which channels are enabled for a user + */ +export interface NotificationPreferences { + /** Whether push notifications are enabled */ + isPushEnabled: boolean; + + /** Whether email notifications are enabled */ + isEmailEnabled: boolean; + + /** Whether SMS notifications are enabled */ + isSmsEnabled: boolean; +} + +/** + * Payload for sending a notification + */ +export interface NotificationPayload { + /** ID of the user to notify */ + userId: string; + + /** Notification title */ + title: string; + + /** Notification message body */ + message: string; + + /** Category/type of notification */ + scope: NotificationScope; + + /** + * User's notification preferences + * If not provided, defaults to push-only + */ + preferences?: NotificationPreferences; + + /** Additional data to attach to the notification */ + data?: Record; +} + +/** + * NotificationFactory + * + * Central service for sending notifications. + * Independent service with no external dependencies (microservice-ready). + * + * Handles: + * - Channel routing based on provided preferences + * - Parallel notification delivery + * - Error handling + * + * Note: Caller is responsible for providing user preferences. + * This keeps the factory independent and testable. + * + * Usage: + * await notificationFactory.send({ + * userId: 'user-123', + * title: 'Transaction Alert', + * message: 'You spent $50.00', + * scope: NotificationScope.CHILD_SPENDING, + * preferences: { + * isPushEnabled: true, + * isEmailEnabled: false, + * isSmsEnabled: false, + * }, + * }); + */ +@Injectable() +export class NotificationFactory { + private readonly logger = new Logger(NotificationFactory.name); + + constructor( + private readonly notificationsService: NotificationsService, + ) {} + + /** + * Send a notification to a user + * Routes to enabled channels based on provided preferences + * + * @param payload - Notification payload including preferences + */ + async send(payload: NotificationPayload): Promise { + try { + this.logger.log(`Sending notification to user ${payload.userId} - ${payload.title}`); + + // Use provided preferences or default to push-only + const preferences = payload.preferences || { + isPushEnabled: true, + isEmailEnabled: false, + isSmsEnabled: false, + }; + + const promises: Promise[] = []; + + // Route to enabled channels based on preferences + // Currently only PUSH is implemented (extensible for EMAIL, SMS later) + if (preferences.isPushEnabled) { + this.logger.debug(`Routing to PUSH channel for user ${payload.userId}`); + promises.push( + this.sendToChannel(payload, NotificationChannel.PUSH) + ); + } + + // Future: Add EMAIL channel + // if (preferences.isEmailEnabled) { + // this.logger.debug(`Routing to EMAIL channel for user ${payload.userId}`); + // promises.push( + // this.sendToChannel(payload, NotificationChannel.EMAIL) + // ); + // } + + // Future: Add SMS channel + // if (preferences.isSmsEnabled) { + // this.logger.debug(`Routing to SMS channel for user ${payload.userId}`); + // promises.push( + // this.sendToChannel(payload, NotificationChannel.SMS) + // ); + // } + + // Send all notificaetions in parallel + await Promise.all(promises); + + this.logger.log( + `Notification sent to user ${payload.userId} via ${promises.length} channel(s)` + ); + } catch (error: any) { + this.logger.error( + `Failed to send notification to user ${payload.userId}: ${error?.message || 'Unknown error'}`, + error?.stack + ); + // Don't throw - prevents breaking the main business flow + // Notification failures should not break transactions, etc. + } + } + + /** + * Send notification via a specific channel + * Creates the notification record and publishes it for delivery + */ + private async sendToChannel( + payload: NotificationPayload, + channel: NotificationChannel + ): Promise { + await this.notificationsService.createNotification({ + userId: payload.userId, + title: payload.title, + message: payload.message, + scope: payload.scope, + channel, + data: payload.data, + }); + } +} + diff --git a/src/db/migrations/1767172707881-AddDataColumnToNotifications.ts b/src/db/migrations/1767172707881-AddDataColumnToNotifications.ts new file mode 100644 index 0000000..aa59cc9 --- /dev/null +++ b/src/db/migrations/1767172707881-AddDataColumnToNotifications.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddDataColumnToNotifications1767172707881 implements MigrationInterface { + name = 'AddDataColumnToNotifications1767172707881' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "notifications" ADD "data" jsonb`); + await queryRunner.query(`ALTER TABLE "kyc_transactions" ALTER COLUMN "national_id" DROP DEFAULT`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "kyc_transactions" ALTER COLUMN "national_id" SET DEFAULT ''`); + await queryRunner.query(`ALTER TABLE "notifications" DROP COLUMN "data"`); + } + +}