mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2026-03-10 15:11:46 +00:00
Compare commits
6 Commits
11b2b25adc
...
652359b1bf
| Author | SHA1 | Date | |
|---|---|---|---|
| 652359b1bf | |||
| 45acf73a4a | |||
| 2d6524be9f | |||
| d3ff755439 | |||
| 3ab00dfc29 | |||
| 21653efc46 |
@ -314,33 +314,54 @@ export class AuthService {
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
try {
|
||||
this.logger.log(`Registering/updating device ${deviceId} with FCM token for user ${userId}`);
|
||||
|
||||
// Check if device already exists for this user
|
||||
const existingDevice = await this.deviceService.findUserDeviceById(deviceId, userId);
|
||||
// Step 1: Check if device already exists for this user
|
||||
const existingDeviceForUser = await this.deviceService.findUserDeviceById(deviceId, userId);
|
||||
|
||||
if (existingDevice) {
|
||||
// Update existing device with new FCM token and last access time
|
||||
if (existingDeviceForUser) {
|
||||
// Device exists for this user → Update FCM token and last access time
|
||||
await this.deviceService.updateDevice(deviceId, {
|
||||
fcmToken,
|
||||
userId,
|
||||
lastAccessOn: new Date(),
|
||||
});
|
||||
this.logger.log(`Device ${deviceId} updated with new FCM token for user ${userId}`);
|
||||
} else {
|
||||
// Create new device
|
||||
await this.deviceService.createDevice({
|
||||
deviceId,
|
||||
return;
|
||||
}
|
||||
|
||||
// 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,
|
||||
fcmToken,
|
||||
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) {
|
||||
// Log error but don't fail the login/signup process
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@ -117,7 +117,10 @@ export class TransactionService {
|
||||
isChildSpending: true,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
console.log(`[TransactionService] Emitting TRANSACTION_CREATED event for transaction ${transaction.id}`);
|
||||
this.eventEmitter.emit(NOTIFICATION_EVENTS.TRANSACTION_CREATED, event);
|
||||
console.log(`[TransactionService] Event emitted successfully`);
|
||||
|
||||
return transaction;
|
||||
}
|
||||
|
||||
@ -5,6 +5,11 @@
|
||||
export const NOTIFICATION_EVENTS = {
|
||||
// Transaction events
|
||||
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;
|
||||
|
||||
export type NotificationEventName =
|
||||
|
||||
@ -13,6 +13,11 @@ export enum NotificationScope {
|
||||
// Transaction notifications - Spending
|
||||
CHILD_SPENDING = 'CHILD_SPENDING',
|
||||
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',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Transaction } from '~/card/entities/transaction.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
|
||||
@ -22,3 +23,41 @@ export interface ITransactionCreatedEvent {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from './notification-created.listener';
|
||||
export * from './transaction-notification.listener';
|
||||
export * from './money-request-notification.listener';
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { I18nService } from 'nestjs-i18n';
|
||||
import { NotificationFactory, NotificationPreferences } from '../services/notification-factory.service';
|
||||
import { UserService } from '~/user/services/user.service';
|
||||
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 { Card } from '~/card/entities/card.entity';
|
||||
import { User } from '~/user/entities';
|
||||
import { UserLocale } from '~/core/enums/user-locale.enum';
|
||||
|
||||
/**
|
||||
* TransactionNotificationListener
|
||||
@ -29,6 +31,7 @@ export class TransactionNotificationListener {
|
||||
constructor(
|
||||
private readonly notificationFactory: NotificationFactory,
|
||||
private readonly userService: UserService,
|
||||
private readonly i18n: I18nService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -38,12 +41,15 @@ export class TransactionNotificationListener {
|
||||
@OnEvent(NOTIFICATION_EVENTS.TRANSACTION_CREATED)
|
||||
async handleTransactionCreated(event: ITransactionCreatedEvent): Promise<void> {
|
||||
try {
|
||||
console.log(`[TransactionNotificationListener] Event received: ${NOTIFICATION_EVENTS.TRANSACTION_CREATED}`);
|
||||
const { transaction, card, isTopUp, isChildSpending } = event;
|
||||
|
||||
this.logger.log(
|
||||
`Processing transaction notification for transaction ${transaction.id} - ` +
|
||||
`isTopUp: ${isTopUp}, isChildSpending: ${isChildSpending}`
|
||||
);
|
||||
|
||||
console.log(`[TransactionNotificationListener] Transaction: ${transaction.id}, Card: ${card?.id}, isTopUp: ${isTopUp}, isChildSpending: ${isChildSpending}`);
|
||||
|
||||
await this.notifyTransactionOwner(transaction, card, isTopUp, isChildSpending);
|
||||
|
||||
@ -59,6 +65,7 @@ export class TransactionNotificationListener {
|
||||
`Transaction notification processed successfully for transaction ${transaction.id}`
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error(`[TransactionNotificationListener] ERROR:`, error);
|
||||
this.logger.error(
|
||||
`Failed to process transaction notification: ${error?.message || 'Unknown error'}`,
|
||||
error?.stack
|
||||
@ -87,18 +94,50 @@ export class TransactionNotificationListener {
|
||||
? NotificationScope.CHILD_TOP_UP
|
||||
: NotificationScope.CHILD_SPENDING;
|
||||
|
||||
const title = isTopUp ? 'Card Topped Up' : 'Purchase Successful';
|
||||
|
||||
const locale = this.getUserLocale(user);
|
||||
const amount = transaction.transactionAmount;
|
||||
const merchant = transaction.merchantName || 'merchant';
|
||||
const balance = card.account?.balance || 0;
|
||||
const currency = card.account?.currency || transaction.transactionCurrency || 'SAR';
|
||||
|
||||
const message = isTopUp
|
||||
? `Your card has been topped up with $${amount.toFixed(2)}`
|
||||
: `You spent $${amount.toFixed(2)} at ${merchant}. Balance: $${balance.toFixed(2)}`;
|
||||
let title: string;
|
||||
let message: string;
|
||||
|
||||
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(
|
||||
`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({
|
||||
@ -110,6 +149,7 @@ export class TransactionNotificationListener {
|
||||
data: {
|
||||
transactionId: transaction.id,
|
||||
amount: amount.toString(),
|
||||
currency: currency,
|
||||
merchant: merchant,
|
||||
merchantCategory: transaction.merchantCategoryCode || 'OTHER',
|
||||
balance: balance.toString(),
|
||||
@ -145,18 +185,44 @@ export class TransactionNotificationListener {
|
||||
}
|
||||
|
||||
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 merchant = transaction.merchantName || 'a merchant';
|
||||
const balance = card.account?.balance || 0;
|
||||
const currency = card.account?.currency || transaction.transactionCurrency || 'SAR';
|
||||
|
||||
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({
|
||||
userId: parentUser.id,
|
||||
title: 'Child Spending Alert',
|
||||
message: `${childName} spent $${amount.toFixed(2)} at ${merchant}`,
|
||||
title,
|
||||
message,
|
||||
scope: NotificationScope.PARENT_SPENDING_ALERT,
|
||||
preferences: this.getUserPreferences(parentUser),
|
||||
data: {
|
||||
@ -164,8 +230,10 @@ export class TransactionNotificationListener {
|
||||
childId: childUser.id,
|
||||
childName: childName,
|
||||
amount: amount.toString(),
|
||||
currency: currency,
|
||||
merchant: merchant,
|
||||
merchantCategory: transaction.merchantCategoryCode || 'OTHER',
|
||||
balance: balance.toString(),
|
||||
timestamp: transaction.transactionDate.toISOString(),
|
||||
type: 'CHILD_SPENDING',
|
||||
action: 'OPEN_TRANSACTION',
|
||||
@ -198,18 +266,42 @@ export class TransactionNotificationListener {
|
||||
}
|
||||
|
||||
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 balance = card.account?.balance || 0;
|
||||
const currency = card.account?.currency || transaction.transactionCurrency || 'SAR';
|
||||
|
||||
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({
|
||||
userId: parentUser.id,
|
||||
title: 'Top-Up Confirmation',
|
||||
message: `You topped up ${childName}'s card with $${amount.toFixed(2)}. New balance: $${balance.toFixed(2)}`,
|
||||
title,
|
||||
message,
|
||||
scope: NotificationScope.PARENT_TOP_UP_CONFIRMATION,
|
||||
preferences: this.getUserPreferences(parentUser),
|
||||
data: {
|
||||
@ -217,6 +309,7 @@ export class TransactionNotificationListener {
|
||||
childId: childUser.id,
|
||||
childName: childName,
|
||||
amount: amount.toString(),
|
||||
currency: currency,
|
||||
balance: balance.toString(),
|
||||
timestamp: transaction.transactionDate.toISOString(),
|
||||
type: 'TOP_UP',
|
||||
@ -244,6 +337,17 @@ export class TransactionNotificationListener {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -8,7 +8,11 @@ import { buildMailerOptions, buildTwilioOptions } from '~/core/module-options';
|
||||
import { UserModule } from '~/user/user.module';
|
||||
import { NotificationsController } from './controllers';
|
||||
import { Notification } from './entities';
|
||||
import { NotificationCreatedListener, TransactionNotificationListener } from './listeners';
|
||||
import {
|
||||
MoneyRequestNotificationListener,
|
||||
NotificationCreatedListener,
|
||||
TransactionNotificationListener,
|
||||
} from './listeners';
|
||||
import { NotificationsRepository } from './repositories';
|
||||
import { FirebaseService, NotificationFactory, NotificationsService, TwilioService } from './services';
|
||||
import { MessagingSystemFactory, RedisPubSubMessagingService } from './services/messaging';
|
||||
@ -35,6 +39,7 @@ import { MessagingSystemFactory, RedisPubSubMessagingService } from './services/
|
||||
TwilioService,
|
||||
NotificationCreatedListener,
|
||||
TransactionNotificationListener,
|
||||
MoneyRequestNotificationListener,
|
||||
RedisPubSubMessagingService,
|
||||
MessagingSystemFactory,
|
||||
],
|
||||
|
||||
@ -110,5 +110,16 @@
|
||||
"INSUFFICIENT_BALANCE": "البطاقة لا تحتوي على رصيد كافٍ لإكمال هذا التحويل.",
|
||||
"DOES_NOT_BELONG_TO_GUARDIAN": "البطاقة لا تنتمي إلى ولي الأمر.",
|
||||
"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": "طفلك"
|
||||
}
|
||||
}
|
||||
|
||||
@ -109,5 +109,16 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,7 +56,33 @@ export class MoneyRequestsRepository {
|
||||
}
|
||||
return this.moneyRequestRepository.findOne({
|
||||
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',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Transactional } from 'typeorm-transactional';
|
||||
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 { Junior } from '~/junior/entities/junior.entity';
|
||||
import { JuniorService } from '~/junior/services';
|
||||
@ -16,10 +23,19 @@ export class MoneyRequestsService {
|
||||
private readonly moneyRequestsRepository: MoneyRequestsRepository,
|
||||
private readonly juniorService: JuniorService,
|
||||
private readonly ociService: OciService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
async createMoneyRequest(juniorId: string, body: CreateMoneyRequestDto) {
|
||||
const junior = await this.juniorService.findJuniorById(juniorId);
|
||||
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);
|
||||
}
|
||||
|
||||
@ -63,6 +79,13 @@ export class MoneyRequestsService {
|
||||
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(
|
||||
@ -85,6 +108,14 @@ export class MoneyRequestsService {
|
||||
}
|
||||
|
||||
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[]) {
|
||||
|
||||
@ -11,6 +11,10 @@ export class DeviceRepository {
|
||||
return this.deviceRepository.findOne({ where: { deviceId, userId } });
|
||||
}
|
||||
|
||||
findByDeviceId(deviceId: string) {
|
||||
return this.deviceRepository.findOne({ where: { deviceId } });
|
||||
}
|
||||
|
||||
createDevice(data: Partial<Device>) {
|
||||
return this.deviceRepository.save(data);
|
||||
}
|
||||
|
||||
@ -10,6 +10,11 @@ export class DeviceService {
|
||||
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>) {
|
||||
this.logger.log(`Creating device with data ${JSON.stringify(data)}`);
|
||||
return this.deviceRepository.createDevice(data);
|
||||
|
||||
Reference in New Issue
Block a user