mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2026-03-10 18:41:46 +00:00
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:
@ -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];
|
||||
|
||||
|
||||
|
||||
|
||||
3
src/common/modules/notification/constants/index.ts
Normal file
3
src/common/modules/notification/constants/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
// Export all constants from this folder
|
||||
export * from './event-names.constant';
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"`);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user