Compare commits

...

6 Commits

Author SHA1 Message Date
652359b1bf Merge pull request #79 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: enhance transaction notification logging and error handling
2026-01-12 16:48:13 +03:00
45acf73a4a feat: enhance transaction notification logging and error handling
- Added console logging for emitted transaction creation events in TransactionService.
- Improved error handling in TransactionNotificationListener for i18n translation failures, providing fallback messages.
- Updated amount parsing in MoneyRequestNotificationListener to ensure consistent handling of string and numeric values.
2026-01-12 16:47:28 +03:00
2d6524be9f Merge pull request #78 from Zod-Alkhair/feature/notification-system-fcm-registration
refactor: standardize notification message formatting
2026-01-12 16:30:32 +03:00
d3ff755439 refactor: standardize notification message formatting
- Updated notification message arguments to use consistent object syntax for better readability.
- Modified Arabic and English translation files to reflect the new argument format in notification messages.
2026-01-12 16:28:26 +03:00
3ab00dfc29 Merge pull request #76 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: implement money request notification system
2026-01-12 16:15:19 +03:00
21653efc46 feat: implement money request notification system
- Added MoneyRequestNotificationListener to handle notifications for money request events (created, approved, declined).
- Introduced new notification event constants for money requests.
- Updated notification interfaces to include money request event payloads.
- Enhanced existing notification system to support money request notifications, notifying parents and children appropriately.
- Updated device service to support finding devices by ID for improved functionality.
2026-01-12 16:07:48 +03:00
15 changed files with 561 additions and 26 deletions

View File

@ -314,33 +314,54 @@ export class AuthService {
/** /**
* Register or update device with FCM token * Register or update device with FCM token
* This method handles both new device registration and existing device updates * This method handles:
* 1. Device already exists for this user → Update FCM token
* 2. Device exists for different user → Transfer device to new user
* 3. Device doesn't exist → Create new device
*/ */
private async registerDeviceToken(userId: string, deviceId: string, fcmToken: string): Promise<void> { private async registerDeviceToken(userId: string, deviceId: string, fcmToken: string): Promise<void> {
try { try {
this.logger.log(`Registering/updating device ${deviceId} with FCM token for user ${userId}`); this.logger.log(`Registering/updating device ${deviceId} with FCM token for user ${userId}`);
// Check if device already exists for this user // Step 1: Check if device already exists for this user
const existingDevice = await this.deviceService.findUserDeviceById(deviceId, userId); const existingDeviceForUser = await this.deviceService.findUserDeviceById(deviceId, userId);
if (existingDevice) { if (existingDeviceForUser) {
// Update existing device with new FCM token and last access time // Device exists for this user → Update FCM token and last access time
await this.deviceService.updateDevice(deviceId, { await this.deviceService.updateDevice(deviceId, {
fcmToken, fcmToken,
userId, userId,
lastAccessOn: new Date(), lastAccessOn: new Date(),
}); });
this.logger.log(`Device ${deviceId} updated with new FCM token for user ${userId}`); this.logger.log(`Device ${deviceId} updated with new FCM token for user ${userId}`);
} else { return;
// Create new device }
await this.deviceService.createDevice({
deviceId, // Step 2: Check if device exists for any user (different user scenario)
const existingDevice = await this.deviceService.findByDeviceId(deviceId);
if (existingDevice) {
// Device exists for different user → Transfer device to new user
this.logger.log(
`Device ${deviceId} exists for user ${existingDevice.userId}, transferring to user ${userId}`
);
await this.deviceService.updateDevice(deviceId, {
userId, userId,
fcmToken, fcmToken,
lastAccessOn: new Date(), lastAccessOn: new Date(),
}); });
this.logger.log(`New device ${deviceId} registered with FCM token for user ${userId}`); this.logger.log(`Device ${deviceId} transferred from user ${existingDevice.userId} to user ${userId}`);
return;
} }
// Step 3: Device doesn't exist → Create new device
await this.deviceService.createDevice({
deviceId,
userId,
fcmToken,
lastAccessOn: new Date(),
});
this.logger.log(`New device ${deviceId} registered with FCM token for user ${userId}`);
} catch (error) { } catch (error) {
// Log error but don't fail the login/signup process // Log error but don't fail the login/signup process
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);

View File

@ -117,7 +117,10 @@ export class TransactionService {
isChildSpending: true, isChildSpending: true,
timestamp: new Date(), timestamp: new Date(),
}; };
console.log(`[TransactionService] Emitting TRANSACTION_CREATED event for transaction ${transaction.id}`);
this.eventEmitter.emit(NOTIFICATION_EVENTS.TRANSACTION_CREATED, event); this.eventEmitter.emit(NOTIFICATION_EVENTS.TRANSACTION_CREATED, event);
console.log(`[TransactionService] Event emitted successfully`);
return transaction; return transaction;
} }

View File

@ -5,6 +5,11 @@
export const NOTIFICATION_EVENTS = { export const NOTIFICATION_EVENTS = {
// Transaction events // Transaction events
TRANSACTION_CREATED: 'notification.transaction.created', TRANSACTION_CREATED: 'notification.transaction.created',
// Money Request events
MONEY_REQUEST_CREATED: 'notification.money-request.created',
MONEY_REQUEST_APPROVED: 'notification.money-request.approved',
MONEY_REQUEST_DECLINED: 'notification.money-request.declined',
} as const; } as const;
export type NotificationEventName = export type NotificationEventName =

View File

@ -13,6 +13,11 @@ export enum NotificationScope {
// Transaction notifications - Spending // Transaction notifications - Spending
CHILD_SPENDING = 'CHILD_SPENDING', CHILD_SPENDING = 'CHILD_SPENDING',
PARENT_SPENDING_ALERT = 'PARENT_SPENDING_ALERT', PARENT_SPENDING_ALERT = 'PARENT_SPENDING_ALERT',
// Money Request notifications
MONEY_REQUEST_CREATED = 'MONEY_REQUEST_CREATED',
MONEY_REQUEST_APPROVED = 'MONEY_REQUEST_APPROVED',
MONEY_REQUEST_DECLINED = 'MONEY_REQUEST_DECLINED',
} }
/** /**

View File

@ -1,5 +1,6 @@
import { Transaction } from '~/card/entities/transaction.entity'; import { Transaction } from '~/card/entities/transaction.entity';
import { Card } from '~/card/entities/card.entity'; import { Card } from '~/card/entities/card.entity';
import { MoneyRequest } from '~/money-request/entities/money-request.entity';
/** /**
* Event payload for when a transaction is created * Event payload for when a transaction is created
@ -22,3 +23,41 @@ export interface ITransactionCreatedEvent {
timestamp: Date; timestamp: Date;
} }
/**
* Event payload for when a money request is created
* Used to notify parents when their child requests money
*/
export interface IMoneyRequestCreatedEvent {
/** The money request that was created */
moneyRequest: MoneyRequest;
/** When the event occurred */
timestamp: Date;
}
/**
* Event payload for when a money request is approved
* Used to notify children when their money request is approved
*/
export interface IMoneyRequestApprovedEvent {
/** The money request that was approved */
moneyRequest: MoneyRequest;
/** When the event occurred */
timestamp: Date;
}
/**
* Event payload for when a money request is declined
* Used to notify children when their money request is declined
*/
export interface IMoneyRequestDeclinedEvent {
/** The money request that was declined */
moneyRequest: MoneyRequest;
/** Rejection reason provided by parent */
rejectionReason?: string;
/** When the event occurred */
timestamp: Date;
}

View File

@ -1,2 +1,3 @@
export * from './notification-created.listener'; export * from './notification-created.listener';
export * from './transaction-notification.listener'; export * from './transaction-notification.listener';
export * from './money-request-notification.listener';

View File

@ -0,0 +1,264 @@
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 {
IMoneyRequestApprovedEvent,
IMoneyRequestCreatedEvent,
IMoneyRequestDeclinedEvent,
} from '../interfaces/notification-events.interface';
import { NotificationScope } from '../enums/notification-scope.enum';
import { User } from '~/user/entities';
/**
* MoneyRequestNotificationListener
*
* Handles notifications for money request events.
* Notifies parents when children request money, and children when requests are approved/declined.
*
* Responsibilities:
* - Listen for money request events (created, approved, declined)
* - Determine notification recipients (parent or child)
* - Construct appropriate messages
* - Fetch user preferences
* - Call NotificationFactory to send
*/
@Injectable()
export class MoneyRequestNotificationListener {
private readonly logger = new Logger(MoneyRequestNotificationListener.name);
constructor(
private readonly notificationFactory: NotificationFactory,
private readonly userService: UserService,
) {}
/**
* Handle money request created event
* Notifies parent when child requests money
*/
@OnEvent(NOTIFICATION_EVENTS.MONEY_REQUEST_CREATED)
async handleMoneyRequestCreated(event: IMoneyRequestCreatedEvent): Promise<void> {
try {
const { moneyRequest } = event;
this.logger.log(
`Processing money request notification for request ${moneyRequest.id} - ` +
`Amount: $${moneyRequest.amount}, Reason: ${moneyRequest.reason}`
);
await this.notifyParentOfMoneyRequest(moneyRequest);
this.logger.log(
`Money request notification processed successfully for request ${moneyRequest.id}`
);
} catch (error: any) {
this.logger.error(
`Failed to process money request notification: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Handle money request approved event
* Notifies child when their money request is approved
*/
@OnEvent(NOTIFICATION_EVENTS.MONEY_REQUEST_APPROVED)
async handleMoneyRequestApproved(event: IMoneyRequestApprovedEvent): Promise<void> {
try {
const { moneyRequest } = event;
this.logger.log(
`Processing money request approved notification for request ${moneyRequest.id}`
);
await this.notifyChildOfApproval(moneyRequest);
this.logger.log(
`Money request approved notification processed successfully for request ${moneyRequest.id}`
);
} catch (error: any) {
this.logger.error(
`Failed to process money request approved notification: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Handle money request declined event
* Notifies child when their money request is declined
*/
@OnEvent(NOTIFICATION_EVENTS.MONEY_REQUEST_DECLINED)
async handleMoneyRequestDeclined(event: IMoneyRequestDeclinedEvent): Promise<void> {
try {
const { moneyRequest, rejectionReason } = event;
this.logger.log(
`Processing money request declined notification for request ${moneyRequest.id}`
);
await this.notifyChildOfRejection(moneyRequest, rejectionReason);
this.logger.log(
`Money request declined notification processed successfully for request ${moneyRequest.id}`
);
} catch (error: any) {
this.logger.error(
`Failed to process money request declined notification: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Notify parent when child requests money
*/
private async notifyParentOfMoneyRequest(moneyRequest: any): Promise<void> {
try {
const guardian = moneyRequest?.guardian;
const parentUser = guardian?.customer?.user;
if (!parentUser) {
this.logger.warn(`No parent user found for money request ${moneyRequest.id}, skipping notification`);
return;
}
const child = moneyRequest?.junior;
const childUser = child?.customer?.user;
const childName = childUser?.firstName || 'Your child';
const amount = typeof moneyRequest.amount === 'string' ? parseFloat(moneyRequest.amount) : moneyRequest.amount;
const reason = moneyRequest.reason || 'No reason provided';
this.logger.debug(
`Notifying parent (user ${parentUser.id}): ${childName} requested $${amount} - ${reason}`
);
await this.notificationFactory.send({
userId: parentUser.id,
title: 'Money Request',
message: `${childName} requested $${amount.toFixed(2)}. Reason: ${reason}`,
scope: NotificationScope.MONEY_REQUEST_CREATED,
preferences: this.getUserPreferences(parentUser),
data: {
moneyRequestId: moneyRequest.id,
childId: childUser?.id,
childName: childName,
amount: amount.toString(),
reason: reason,
timestamp: moneyRequest.createdAt.toISOString(),
type: 'MONEY_REQUEST',
action: 'VIEW_MONEY_REQUEST',
},
});
this.logger.log(`✅ Notified parent ${parentUser.id} about money request ${moneyRequest.id}`);
} catch (error: any) {
this.logger.error(
`Failed to notify parent of money request: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Notify child when their money request is approved
*/
private async notifyChildOfApproval(moneyRequest: any): Promise<void> {
try {
const child = moneyRequest?.junior;
const childUser = child?.customer?.user;
if (!childUser) {
this.logger.warn(`No child user found for money request ${moneyRequest.id}, skipping notification`);
return;
}
const amount = typeof moneyRequest.amount === 'string' ? parseFloat(moneyRequest.amount) : moneyRequest.amount;
this.logger.debug(
`Notifying child (user ${childUser.id}): Money request of $${amount} was approved`
);
await this.notificationFactory.send({
userId: childUser.id,
title: 'Money Request Approved',
message: `Your request for $${amount.toFixed(2)} has been approved. The money has been added to your account.`,
scope: NotificationScope.MONEY_REQUEST_APPROVED,
preferences: this.getUserPreferences(childUser),
data: {
moneyRequestId: moneyRequest.id,
amount: amount.toString(),
timestamp: moneyRequest.updatedAt.toISOString(),
type: 'MONEY_REQUEST_APPROVED',
action: 'VIEW_MONEY_REQUEST',
},
});
this.logger.log(`✅ Notified child ${childUser.id} about approved money request ${moneyRequest.id}`);
} catch (error: any) {
this.logger.error(
`Failed to notify child of approval: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Notify child when their money request is declined
*/
private async notifyChildOfRejection(moneyRequest: any, rejectionReason?: string): Promise<void> {
try {
const child = moneyRequest?.junior;
const childUser = child?.customer?.user;
if (!childUser) {
this.logger.warn(`No child user found for money request ${moneyRequest.id}, skipping notification`);
return;
}
const amount = typeof moneyRequest.amount === 'string' ? parseFloat(moneyRequest.amount) : moneyRequest.amount;
const reason = rejectionReason || 'No reason provided';
this.logger.debug(
`Notifying child (user ${childUser.id}): Money request of $${amount} was declined`
);
await this.notificationFactory.send({
userId: childUser.id,
title: 'Money Request Declined',
message: `Your request for $${amount.toFixed(2)} has been declined. Reason: ${reason}`,
scope: NotificationScope.MONEY_REQUEST_DECLINED,
preferences: this.getUserPreferences(childUser),
data: {
moneyRequestId: moneyRequest.id,
amount: amount.toString(),
rejectionReason: reason,
timestamp: moneyRequest.updatedAt.toISOString(),
type: 'MONEY_REQUEST_DECLINED',
action: 'VIEW_MONEY_REQUEST',
},
});
this.logger.log(`✅ Notified child ${childUser.id} about declined money request ${moneyRequest.id}`);
} catch (error: any) {
this.logger.error(
`Failed to notify child of rejection: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* 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

@ -1,5 +1,6 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import { I18nService } from 'nestjs-i18n';
import { NotificationFactory, NotificationPreferences } from '../services/notification-factory.service'; import { NotificationFactory, NotificationPreferences } from '../services/notification-factory.service';
import { UserService } from '~/user/services/user.service'; import { UserService } from '~/user/services/user.service';
import { NOTIFICATION_EVENTS } from '../constants/event-names.constant'; import { NOTIFICATION_EVENTS } from '../constants/event-names.constant';
@ -8,6 +9,7 @@ import { NotificationScope } from '../enums/notification-scope.enum';
import { Transaction } from '~/card/entities/transaction.entity'; import { Transaction } from '~/card/entities/transaction.entity';
import { Card } from '~/card/entities/card.entity'; import { Card } from '~/card/entities/card.entity';
import { User } from '~/user/entities'; import { User } from '~/user/entities';
import { UserLocale } from '~/core/enums/user-locale.enum';
/** /**
* TransactionNotificationListener * TransactionNotificationListener
@ -29,6 +31,7 @@ export class TransactionNotificationListener {
constructor( constructor(
private readonly notificationFactory: NotificationFactory, private readonly notificationFactory: NotificationFactory,
private readonly userService: UserService, private readonly userService: UserService,
private readonly i18n: I18nService,
) {} ) {}
/** /**
@ -38,12 +41,15 @@ export class TransactionNotificationListener {
@OnEvent(NOTIFICATION_EVENTS.TRANSACTION_CREATED) @OnEvent(NOTIFICATION_EVENTS.TRANSACTION_CREATED)
async handleTransactionCreated(event: ITransactionCreatedEvent): Promise<void> { async handleTransactionCreated(event: ITransactionCreatedEvent): Promise<void> {
try { try {
console.log(`[TransactionNotificationListener] Event received: ${NOTIFICATION_EVENTS.TRANSACTION_CREATED}`);
const { transaction, card, isTopUp, isChildSpending } = event; const { transaction, card, isTopUp, isChildSpending } = event;
this.logger.log( this.logger.log(
`Processing transaction notification for transaction ${transaction.id} - ` + `Processing transaction notification for transaction ${transaction.id} - ` +
`isTopUp: ${isTopUp}, isChildSpending: ${isChildSpending}` `isTopUp: ${isTopUp}, isChildSpending: ${isChildSpending}`
); );
console.log(`[TransactionNotificationListener] Transaction: ${transaction.id}, Card: ${card?.id}, isTopUp: ${isTopUp}, isChildSpending: ${isChildSpending}`);
await this.notifyTransactionOwner(transaction, card, isTopUp, isChildSpending); await this.notifyTransactionOwner(transaction, card, isTopUp, isChildSpending);
@ -59,6 +65,7 @@ export class TransactionNotificationListener {
`Transaction notification processed successfully for transaction ${transaction.id}` `Transaction notification processed successfully for transaction ${transaction.id}`
); );
} catch (error: any) { } catch (error: any) {
console.error(`[TransactionNotificationListener] ERROR:`, error);
this.logger.error( this.logger.error(
`Failed to process transaction notification: ${error?.message || 'Unknown error'}`, `Failed to process transaction notification: ${error?.message || 'Unknown error'}`,
error?.stack error?.stack
@ -87,18 +94,50 @@ export class TransactionNotificationListener {
? NotificationScope.CHILD_TOP_UP ? NotificationScope.CHILD_TOP_UP
: NotificationScope.CHILD_SPENDING; : NotificationScope.CHILD_SPENDING;
const title = isTopUp ? 'Card Topped Up' : 'Purchase Successful'; const locale = this.getUserLocale(user);
const amount = transaction.transactionAmount; const amount = transaction.transactionAmount;
const merchant = transaction.merchantName || 'merchant'; const merchant = transaction.merchantName || 'merchant';
const balance = card.account?.balance || 0; const balance = card.account?.balance || 0;
const currency = card.account?.currency || transaction.transactionCurrency || 'SAR';
const message = isTopUp let title: string;
? `Your card has been topped up with $${amount.toFixed(2)}` let message: string;
: `You spent $${amount.toFixed(2)} at ${merchant}. Balance: $${balance.toFixed(2)}`;
try {
title = isTopUp
? this.i18n.t('app.NOTIFICATION.CHILD_TOP_UP_TITLE', { lang: locale })
: this.i18n.t('app.NOTIFICATION.CHILD_SPENDING_TITLE', { lang: locale });
message = isTopUp
? this.i18n.t('app.NOTIFICATION.CHILD_TOP_UP_MESSAGE', {
lang: locale,
args: {
amount: amount.toString(),
currency: currency,
balance: balance.toString(),
},
})
: this.i18n.t('app.NOTIFICATION.CHILD_SPENDING_MESSAGE', {
lang: locale,
args: {
amount: amount.toString(),
currency: currency,
merchant: merchant,
balance: balance.toString(),
},
});
} catch (i18nError: any) {
console.error(`[TransactionNotificationListener] i18n error:`, i18nError);
this.logger.error(`i18n translation failed: ${i18nError?.message}`, i18nError?.stack);
// Fallback to English without i18n
title = isTopUp ? 'Card Topped Up' : 'Purchase Successful';
message = isTopUp
? `You received ${amount} ${currency}. Total balance: ${balance} ${currency}`
: `You spent ${amount} ${currency} at ${merchant}. Balance: ${balance} ${currency}`;
}
this.logger.debug( this.logger.debug(
`Notifying transaction owner (user ${user.id}) - Amount: $${amount}, Merchant: ${merchant}` `Notifying transaction owner (user ${user.id}) - Amount: ${amount} ${currency}, Merchant: ${merchant}`
); );
await this.notificationFactory.send({ await this.notificationFactory.send({
@ -110,6 +149,7 @@ export class TransactionNotificationListener {
data: { data: {
transactionId: transaction.id, transactionId: transaction.id,
amount: amount.toString(), amount: amount.toString(),
currency: currency,
merchant: merchant, merchant: merchant,
merchantCategory: transaction.merchantCategoryCode || 'OTHER', merchantCategory: transaction.merchantCategoryCode || 'OTHER',
balance: balance.toString(), balance: balance.toString(),
@ -145,18 +185,44 @@ export class TransactionNotificationListener {
} }
const childUser = customer.user; const childUser = customer.user;
const childName = childUser?.firstName || 'Your child'; const locale = this.getUserLocale(parentUser);
const defaultChildName = this.i18n.t('app.NOTIFICATION.YOUR_CHILD', { lang: locale });
const childName = childUser?.firstName || defaultChildName;
const amount = transaction.transactionAmount; const amount = transaction.transactionAmount;
const merchant = transaction.merchantName || 'a merchant'; const merchant = transaction.merchantName || 'a merchant';
const balance = card.account?.balance || 0;
const currency = card.account?.currency || transaction.transactionCurrency || 'SAR';
this.logger.debug( this.logger.debug(
`Notifying parent (user ${parentUser.id}): ${childName} spent $${amount} at ${merchant}` `Notifying parent (user ${parentUser.id}): ${childName} spent ${amount} ${currency} at ${merchant}`
); );
let title: string;
let message: string;
try {
title = this.i18n.t('app.NOTIFICATION.PARENT_SPENDING_TITLE', { lang: locale });
message = this.i18n.t('app.NOTIFICATION.PARENT_SPENDING_MESSAGE', {
lang: locale,
args: {
childName: childName,
amount: amount.toString(),
currency: currency,
merchant: merchant,
balance: balance.toString(),
},
});
} catch (i18nError: any) {
console.error(`[TransactionNotificationListener] i18n error in parent spending:`, i18nError);
this.logger.error(`i18n translation failed: ${i18nError?.message}`, i18nError?.stack);
title = 'Child Spending Alert';
message = `${childName} spent ${amount} ${currency} at ${merchant}. Balance: ${balance} ${currency}`;
}
await this.notificationFactory.send({ await this.notificationFactory.send({
userId: parentUser.id, userId: parentUser.id,
title: 'Child Spending Alert', title,
message: `${childName} spent $${amount.toFixed(2)} at ${merchant}`, message,
scope: NotificationScope.PARENT_SPENDING_ALERT, scope: NotificationScope.PARENT_SPENDING_ALERT,
preferences: this.getUserPreferences(parentUser), preferences: this.getUserPreferences(parentUser),
data: { data: {
@ -164,8 +230,10 @@ export class TransactionNotificationListener {
childId: childUser.id, childId: childUser.id,
childName: childName, childName: childName,
amount: amount.toString(), amount: amount.toString(),
currency: currency,
merchant: merchant, merchant: merchant,
merchantCategory: transaction.merchantCategoryCode || 'OTHER', merchantCategory: transaction.merchantCategoryCode || 'OTHER',
balance: balance.toString(),
timestamp: transaction.transactionDate.toISOString(), timestamp: transaction.transactionDate.toISOString(),
type: 'CHILD_SPENDING', type: 'CHILD_SPENDING',
action: 'OPEN_TRANSACTION', action: 'OPEN_TRANSACTION',
@ -198,18 +266,42 @@ export class TransactionNotificationListener {
} }
const childUser = customer.user; const childUser = customer.user;
const childName = childUser?.firstName || 'Your child'; const locale = this.getUserLocale(parentUser);
const defaultChildName = this.i18n.t('app.NOTIFICATION.YOUR_CHILD', { lang: locale });
const childName = childUser?.firstName || defaultChildName;
const amount = transaction.transactionAmount; const amount = transaction.transactionAmount;
const balance = card.account?.balance || 0; const balance = card.account?.balance || 0;
const currency = card.account?.currency || transaction.transactionCurrency || 'SAR';
this.logger.debug( this.logger.debug(
`Notifying parent (user ${parentUser.id}): Topped up ${childName}'s card with $${amount}` `Notifying parent (user ${parentUser.id}): Transferred ${amount} ${currency} to ${childName}`
); );
let title: string;
let message: string;
try {
title = this.i18n.t('app.NOTIFICATION.PARENT_TOP_UP_TITLE', { lang: locale });
message = this.i18n.t('app.NOTIFICATION.PARENT_TOP_UP_MESSAGE', {
lang: locale,
args: {
amount: amount.toString(),
currency: currency,
childName: childName,
balance: balance.toString(),
},
});
} catch (i18nError: any) {
console.error(`[TransactionNotificationListener] i18n error in parent top-up:`, i18nError);
this.logger.error(`i18n translation failed: ${i18nError?.message}`, i18nError?.stack);
title = 'Top-Up Confirmation';
message = `You transferred ${amount} ${currency} to ${childName}. Balance: ${balance} ${currency}`;
}
await this.notificationFactory.send({ await this.notificationFactory.send({
userId: parentUser.id, userId: parentUser.id,
title: 'Top-Up Confirmation', title,
message: `You topped up ${childName}'s card with $${amount.toFixed(2)}. New balance: $${balance.toFixed(2)}`, message,
scope: NotificationScope.PARENT_TOP_UP_CONFIRMATION, scope: NotificationScope.PARENT_TOP_UP_CONFIRMATION,
preferences: this.getUserPreferences(parentUser), preferences: this.getUserPreferences(parentUser),
data: { data: {
@ -217,6 +309,7 @@ export class TransactionNotificationListener {
childId: childUser.id, childId: childUser.id,
childName: childName, childName: childName,
amount: amount.toString(), amount: amount.toString(),
currency: currency,
balance: balance.toString(), balance: balance.toString(),
timestamp: transaction.transactionDate.toISOString(), timestamp: transaction.transactionDate.toISOString(),
type: 'TOP_UP', type: 'TOP_UP',
@ -244,6 +337,17 @@ export class TransactionNotificationListener {
isSmsEnabled: user.isSmsEnabled, isSmsEnabled: user.isSmsEnabled,
}; };
} }
/**
* Get user locale for i18n translations
* Defaults to English if not specified
* TODO: Add locale field to User entity in the future
*/
private getUserLocale(user: User): UserLocale {
// For now, default to English
// In the future, this can read from user.locale or user.preferences.locale
return UserLocale.ENGLISH;
}
} }

View File

@ -8,7 +8,11 @@ import { buildMailerOptions, buildTwilioOptions } from '~/core/module-options';
import { UserModule } from '~/user/user.module'; import { UserModule } from '~/user/user.module';
import { NotificationsController } from './controllers'; import { NotificationsController } from './controllers';
import { Notification } from './entities'; import { Notification } from './entities';
import { NotificationCreatedListener, TransactionNotificationListener } from './listeners'; import {
MoneyRequestNotificationListener,
NotificationCreatedListener,
TransactionNotificationListener,
} from './listeners';
import { NotificationsRepository } from './repositories'; import { NotificationsRepository } from './repositories';
import { FirebaseService, NotificationFactory, NotificationsService, TwilioService } from './services'; import { FirebaseService, NotificationFactory, NotificationsService, TwilioService } from './services';
import { MessagingSystemFactory, RedisPubSubMessagingService } from './services/messaging'; import { MessagingSystemFactory, RedisPubSubMessagingService } from './services/messaging';
@ -35,6 +39,7 @@ import { MessagingSystemFactory, RedisPubSubMessagingService } from './services/
TwilioService, TwilioService,
NotificationCreatedListener, NotificationCreatedListener,
TransactionNotificationListener, TransactionNotificationListener,
MoneyRequestNotificationListener,
RedisPubSubMessagingService, RedisPubSubMessagingService,
MessagingSystemFactory, MessagingSystemFactory,
], ],

View File

@ -110,5 +110,16 @@
"INSUFFICIENT_BALANCE": "البطاقة لا تحتوي على رصيد كافٍ لإكمال هذا التحويل.", "INSUFFICIENT_BALANCE": "البطاقة لا تحتوي على رصيد كافٍ لإكمال هذا التحويل.",
"DOES_NOT_BELONG_TO_GUARDIAN": "البطاقة لا تنتمي إلى ولي الأمر.", "DOES_NOT_BELONG_TO_GUARDIAN": "البطاقة لا تنتمي إلى ولي الأمر.",
"NOT_FOUND": "لم يتم العثور على البطاقة." "NOT_FOUND": "لم يتم العثور على البطاقة."
},
"NOTIFICATION": {
"CHILD_TOP_UP_TITLE": "تم شحن البطاقة",
"CHILD_TOP_UP_MESSAGE": "لقد استلمت {amount} {currency}. الرصيد الإجمالي: {balance} {currency}",
"CHILD_SPENDING_TITLE": "تمت العملية بنجاح",
"CHILD_SPENDING_MESSAGE": "لقد أنفقت {amount} {currency} في {merchant}. الرصيد: {balance} {currency}",
"PARENT_TOP_UP_TITLE": "تأكيد الشحن",
"PARENT_TOP_UP_MESSAGE": "لقد قمت بتحويل {amount} {currency} إلى {childName}. الرصيد: {balance} {currency}",
"PARENT_SPENDING_TITLE": "تنبيه إنفاق الطفل",
"PARENT_SPENDING_MESSAGE": "أنفق {childName} {amount} {currency} في {merchant}. الرصيد: {balance} {currency}",
"YOUR_CHILD": "طفلك"
} }
} }

View File

@ -109,5 +109,16 @@
"INSUFFICIENT_BALANCE": "The card does not have sufficient balance to complete this transfer.", "INSUFFICIENT_BALANCE": "The card does not have sufficient balance to complete this transfer.",
"DOES_NOT_BELONG_TO_GUARDIAN": "The card does not belong to the guardian.", "DOES_NOT_BELONG_TO_GUARDIAN": "The card does not belong to the guardian.",
"NOT_FOUND": "The card was not found." "NOT_FOUND": "The card was not found."
},
"NOTIFICATION": {
"CHILD_TOP_UP_TITLE": "Card Topped Up",
"CHILD_TOP_UP_MESSAGE": "You received {amount} {currency}. Total balance: {balance} {currency}",
"CHILD_SPENDING_TITLE": "Purchase Successful",
"CHILD_SPENDING_MESSAGE": "You spent {amount} {currency} at {merchant}. Balance: {balance} {currency}",
"PARENT_TOP_UP_TITLE": "Top-Up Confirmation",
"PARENT_TOP_UP_MESSAGE": "You transferred {amount} {currency} to {childName}. Balance: {balance} {currency}",
"PARENT_SPENDING_TITLE": "Child Spending Alert",
"PARENT_SPENDING_MESSAGE": "{childName} spent {amount} {currency} at {merchant}. Balance: {balance} {currency}",
"YOUR_CHILD": "Your child"
} }
} }

View File

@ -56,7 +56,33 @@ export class MoneyRequestsRepository {
} }
return this.moneyRequestRepository.findOne({ return this.moneyRequestRepository.findOne({
where: whereCondition, where: whereCondition,
relations: ['junior', 'junior.customer', 'junior.customer.user', 'junior.customer.user.profilePicture'], relations: [
'junior',
'junior.customer',
'junior.customer.user',
'junior.customer.user.profilePicture',
],
});
}
findByIdWithAllRelations(id: string, userId?: string, role?: Roles): Promise<MoneyRequest | null> {
const whereCondition: any = { id };
if (role === Roles.JUNIOR) {
whereCondition.juniorId = userId;
} else {
whereCondition.guardianId = userId;
}
return this.moneyRequestRepository.findOne({
where: whereCondition,
relations: [
'junior',
'junior.customer',
'junior.customer.user',
'junior.customer.user.profilePicture',
'guardian',
'guardian.customer',
'guardian.customer.user',
],
}); });
} }

View File

@ -1,6 +1,13 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Transactional } from 'typeorm-transactional'; import { Transactional } from 'typeorm-transactional';
import { Roles } from '~/auth/enums'; import { Roles } from '~/auth/enums';
import { NOTIFICATION_EVENTS } from '~/common/modules/notification/constants/event-names.constant';
import {
IMoneyRequestApprovedEvent,
IMoneyRequestCreatedEvent,
IMoneyRequestDeclinedEvent,
} from '~/common/modules/notification/interfaces/notification-events.interface';
import { OciService } from '~/document/services'; import { OciService } from '~/document/services';
import { Junior } from '~/junior/entities/junior.entity'; import { Junior } from '~/junior/entities/junior.entity';
import { JuniorService } from '~/junior/services'; import { JuniorService } from '~/junior/services';
@ -16,10 +23,19 @@ export class MoneyRequestsService {
private readonly moneyRequestsRepository: MoneyRequestsRepository, private readonly moneyRequestsRepository: MoneyRequestsRepository,
private readonly juniorService: JuniorService, private readonly juniorService: JuniorService,
private readonly ociService: OciService, private readonly ociService: OciService,
private readonly eventEmitter: EventEmitter2,
) {} ) {}
async createMoneyRequest(juniorId: string, body: CreateMoneyRequestDto) { async createMoneyRequest(juniorId: string, body: CreateMoneyRequestDto) {
const junior = await this.juniorService.findJuniorById(juniorId); const junior = await this.juniorService.findJuniorById(juniorId);
const moneyRequest = await this.moneyRequestsRepository.createMoneyRequest(junior.id, junior.guardianId, body); const moneyRequest = await this.moneyRequestsRepository.createMoneyRequest(junior.id, junior.guardianId, body);
const moneyRequestWithRelations = await this.moneyRequestsRepository.findByIdWithAllRelations(moneyRequest.id);
const event: IMoneyRequestCreatedEvent = {
moneyRequest: moneyRequestWithRelations!,
timestamp: new Date(),
};
this.eventEmitter.emit(NOTIFICATION_EVENTS.MONEY_REQUEST_CREATED, event);
return this.findById(moneyRequest.id); return this.findById(moneyRequest.id);
} }
@ -63,6 +79,13 @@ export class MoneyRequestsService {
moneyRequest.guardianId, moneyRequest.guardianId,
), ),
]); ]);
const updatedMoneyRequest = await this.moneyRequestsRepository.findByIdWithAllRelations(id, guardianId, Roles.GUARDIAN);
const event: IMoneyRequestApprovedEvent = {
moneyRequest: updatedMoneyRequest!,
timestamp: new Date(),
};
this.eventEmitter.emit(NOTIFICATION_EVENTS.MONEY_REQUEST_APPROVED, event);
} }
async rejectMoneyRequest( async rejectMoneyRequest(
@ -85,6 +108,14 @@ export class MoneyRequestsService {
} }
await this.moneyRequestsRepository.rejectMoneyRequest(id, rejectionReasondto?.rejectionReason); await this.moneyRequestsRepository.rejectMoneyRequest(id, rejectionReasondto?.rejectionReason);
const updatedMoneyRequest = await this.moneyRequestsRepository.findByIdWithAllRelations(id, guardianId, Roles.GUARDIAN);
const event: IMoneyRequestDeclinedEvent = {
moneyRequest: updatedMoneyRequest!,
rejectionReason: rejectionReasondto?.rejectionReason,
timestamp: new Date(),
};
this.eventEmitter.emit(NOTIFICATION_EVENTS.MONEY_REQUEST_DECLINED, event);
} }
private async prepareJuniorImages(juniors: Junior[]) { private async prepareJuniorImages(juniors: Junior[]) {

View File

@ -11,6 +11,10 @@ export class DeviceRepository {
return this.deviceRepository.findOne({ where: { deviceId, userId } }); return this.deviceRepository.findOne({ where: { deviceId, userId } });
} }
findByDeviceId(deviceId: string) {
return this.deviceRepository.findOne({ where: { deviceId } });
}
createDevice(data: Partial<Device>) { createDevice(data: Partial<Device>) {
return this.deviceRepository.save(data); return this.deviceRepository.save(data);
} }

View File

@ -10,6 +10,11 @@ export class DeviceService {
return this.deviceRepository.findUserDeviceById(deviceId, userId); return this.deviceRepository.findUserDeviceById(deviceId, userId);
} }
findByDeviceId(deviceId: string) {
this.logger.log(`Finding device with id ${deviceId} (any user)`);
return this.deviceRepository.findByDeviceId(deviceId);
}
createDevice(data: Partial<Device>) { createDevice(data: Partial<Device>) {
this.logger.log(`Creating device with data ${JSON.stringify(data)}`); this.logger.log(`Creating device with data ${JSON.stringify(data)}`);
return this.deviceRepository.createDevice(data); return this.deviceRepository.createDevice(data);