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
This commit is contained in:
Abdalhamid Alhamad
2026-01-06 12:38:19 +03:00
parent 9c93a35093
commit 93b509b256
6 changed files with 424 additions and 0 deletions

View File

@ -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];

View File

@ -0,0 +1,3 @@
// Export all constants from this folder
export * from './event-names.constant';

View File

@ -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;
}

View File

@ -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<void> {
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<void> {
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<void> {
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,
};
}
}

View File

@ -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<string, any>;
}
/**
* 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<void> {
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<any>[] = [];
// 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<void> {
await this.notificationsService.createNotification({
userId: payload.userId,
title: payload.title,
message: payload.message,
scope: payload.scope,
channel,
data: payload.data,
});
}
}

View File

@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddDataColumnToNotifications1767172707881 implements MigrationInterface {
name = 'AddDataColumnToNotifications1767172707881'
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`ALTER TABLE "kyc_transactions" ALTER COLUMN "national_id" SET DEFAULT ''`);
await queryRunner.query(`ALTER TABLE "notifications" DROP COLUMN "data"`);
}
}