mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2026-03-10 15:11:46 +00:00
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.
This commit is contained in:
@ -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);
|
||||
|
||||
@ -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 = 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 = 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 = 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,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -87,18 +90,28 @@ 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 title = isTopUp
|
||||
? this.i18n.t('app.NOTIFICATION.CHILD_TOP_UP_TITLE', { lang: locale })
|
||||
: this.i18n.t('app.NOTIFICATION.CHILD_SPENDING_TITLE', { lang: locale });
|
||||
|
||||
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.i18n.t('app.NOTIFICATION.CHILD_TOP_UP_MESSAGE', {
|
||||
lang: locale,
|
||||
args: { amount: amount.toString(), currency, balance: balance.toString() },
|
||||
})
|
||||
: this.i18n.t('app.NOTIFICATION.CHILD_SPENDING_MESSAGE', {
|
||||
lang: locale,
|
||||
args: { amount: amount.toString(), currency, merchant, balance: balance.toString() },
|
||||
});
|
||||
|
||||
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 +123,7 @@ export class TransactionNotificationListener {
|
||||
data: {
|
||||
transactionId: transaction.id,
|
||||
amount: amount.toString(),
|
||||
currency: currency,
|
||||
merchant: merchant,
|
||||
merchantCategory: transaction.merchantCategoryCode || 'OTHER',
|
||||
balance: balance.toString(),
|
||||
@ -145,18 +159,28 @@ 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}`
|
||||
);
|
||||
|
||||
const title = this.i18n.t('app.NOTIFICATION.PARENT_SPENDING_TITLE', { lang: locale });
|
||||
const message = this.i18n.t('app.NOTIFICATION.PARENT_SPENDING_MESSAGE', {
|
||||
lang: locale,
|
||||
args: { childName, amount: amount.toString(), currency, merchant, balance: balance.toString() },
|
||||
});
|
||||
|
||||
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 +188,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 +224,27 @@ 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}`
|
||||
);
|
||||
|
||||
const title = this.i18n.t('app.NOTIFICATION.PARENT_TOP_UP_TITLE', { lang: locale });
|
||||
const message = this.i18n.t('app.NOTIFICATION.PARENT_TOP_UP_MESSAGE', {
|
||||
lang: locale,
|
||||
args: { amount: amount.toString(), currency, childName, balance: balance.toString() },
|
||||
});
|
||||
|
||||
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 +252,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 +280,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