From 145e6c62b8fd4120544b3c4802e8e7eebd9ae81b Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Wed, 14 Jan 2026 12:31:48 +0300 Subject: [PATCH] feat: implement KYC and card notification events - Added KycNotificationListener to handle notifications for KYC approval and rejection events. - Introduced CardNotificationListener to manage notifications for card creation and blocking events. - Enhanced CardService to emit events for card creation and blocking, integrating with the new notification system. - Updated notification constants and interfaces to include new KYC and card-related events. - Improved notification message formatting and added localization support for new events. --- src/card/services/card.service.ts | 45 ++- .../constants/event-names.constant.ts | 17 ++ .../response/notifications.response.dto.ts | 9 +- .../enums/notification-scope.enum.ts | 17 ++ .../notification-events.interface.ts | 158 ++++++++++ .../listeners/card-notification.listener.ts | 162 ++++++++++ .../modules/notification/listeners/index.ts | 4 + .../listeners/kyc-notification.listener.ts | 233 +++++++++++++++ .../money-request-notification.listener.ts | 66 +++- .../notification-created.listener.ts | 60 ++-- .../profile-notification.listener.ts | 149 +++++++++ .../system-alert-notification.listener.ts | 282 ++++++++++++++++++ .../transaction-notification.listener.ts | 111 ++++--- .../notification/notification.module.ts | 8 + .../services/notification-factory.service.ts | 24 +- src/common/utils/currency.util.ts | 104 +++++++ src/customer/services/customer.service.ts | 32 ++ src/i18n/ar/app.json | 32 +- src/i18n/en/app.json | 32 +- .../repositories/money-requests.repository.ts | 2 + src/user/controllers/user.controller.ts | 17 +- ...date-notifications-settings.request.dto.ts | 24 +- .../notifications-settings.response.dto.ts | 10 + src/user/entities/user.entity.ts | 2 +- src/user/repositories/device.repository.ts | 4 + src/user/services/device.service.ts | 5 + src/user/services/user.service.ts | 77 ++++- 27 files changed, 1570 insertions(+), 116 deletions(-) create mode 100644 src/common/modules/notification/listeners/card-notification.listener.ts create mode 100644 src/common/modules/notification/listeners/kyc-notification.listener.ts create mode 100644 src/common/modules/notification/listeners/profile-notification.listener.ts create mode 100644 src/common/modules/notification/listeners/system-alert-notification.listener.ts create mode 100644 src/common/utils/currency.util.ts create mode 100644 src/user/dtos/response/notifications-settings.response.dto.ts diff --git a/src/card/services/card.service.ts b/src/card/services/card.service.ts index 5be8fbc..1393fa1 100644 --- a/src/card/services/card.service.ts +++ b/src/card/services/card.service.ts @@ -1,6 +1,9 @@ import { BadRequestException, forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import Decimal from 'decimal.js'; import { Transactional } from 'typeorm-transactional'; +import { NOTIFICATION_EVENTS } from '~/common/modules/notification/constants/event-names.constant'; +import { ICardBlockedEvent, ICardCreatedEvent } from '~/common/modules/notification/interfaces/notification-events.interface'; import { AccountCardStatusChangedWebhookRequest } from '~/common/modules/neoleap/dtos/requests'; import { NeoLeapService } from '~/common/modules/neoleap/services'; import { Customer } from '~/customer/entities'; @@ -8,7 +11,7 @@ import { KycStatus } from '~/customer/enums'; import { CustomerService } from '~/customer/services'; import { OciService } from '~/document/services'; import { Card } from '../entities'; -import { CardColors } from '../enums'; +import { CardColors, CardStatus } from '../enums'; import { CardStatusMapper } from '../mappers/card-status.mapper'; import { CardRepository } from '../repositories'; import { AccountService } from './account.service'; @@ -24,6 +27,7 @@ export class CardService { @Inject(forwardRef(() => TransactionService)) private readonly transactionService: TransactionService, @Inject(forwardRef(() => NeoLeapService)) private readonly neoleapService: NeoLeapService, @Inject(forwardRef(() => CustomerService)) private readonly customerService: CustomerService, + private readonly eventEmitter: EventEmitter2, ) {} @Transactional() @@ -58,7 +62,16 @@ export class CardService { const account = await this.accountService.createAccount(data); const createdCard = await this.cardRepository.createCard(customerId, account.id, data); - return this.getCardById(createdCard.id); + const cardWithRelations = await this.getCardById(createdCard.id); + + const event: ICardCreatedEvent = { + card: cardWithRelations, + timestamp: new Date(), + }; + this.eventEmitter.emit(NOTIFICATION_EVENTS.CARD_CREATED, event); + this.logger.log(`Emitted CARD_CREATED event for card ${cardWithRelations.id}`); + + return cardWithRelations; } async getChildCards(guardianId: string): Promise { @@ -77,7 +90,16 @@ export class CardService { parentCustomer.id, ); - return this.getCardById(createdCard.id); + const cardWithRelations = await this.getCardById(createdCard.id); + + const event: ICardCreatedEvent = { + card: cardWithRelations, + timestamp: new Date(), + }; + this.eventEmitter.emit(NOTIFICATION_EVENTS.CARD_CREATED, event); + this.logger.log(`Emitted CARD_CREATED event for child card ${cardWithRelations.id}`); + + return cardWithRelations; } async getCardByChildId(guardianId: string, childId: string): Promise { @@ -128,9 +150,24 @@ export class CardService { async updateCardStatus(body: AccountCardStatusChangedWebhookRequest) { const card = await this.getCardByVpan(body.cardId); + const previousStatus = card.status; const { description, status } = CardStatusMapper[body.newStatus] || CardStatusMapper['99']; - return this.cardRepository.updateCardStatus(card.id, status, description); + await this.cardRepository.updateCardStatus(card.id, status, description); + + if (status === CardStatus.BLOCKED) { + const updatedCard = await this.getCardById(card.id); + const event: ICardBlockedEvent = { + card: updatedCard, + previousStatus, + blockReason: description, + timestamp: new Date(), + }; + this.eventEmitter.emit(NOTIFICATION_EVENTS.CARD_BLOCKED, event); + this.logger.log(`Emitted CARD_BLOCKED event for card ${updatedCard.id}`); + } + + return { id: card.id, status, description }; } async getEmbossingInformation(customerId: string) { diff --git a/src/common/modules/notification/constants/event-names.constant.ts b/src/common/modules/notification/constants/event-names.constant.ts index 9854c6e..94a2b5c 100644 --- a/src/common/modules/notification/constants/event-names.constant.ts +++ b/src/common/modules/notification/constants/event-names.constant.ts @@ -10,6 +10,23 @@ export const NOTIFICATION_EVENTS = { MONEY_REQUEST_CREATED: 'notification.money-request.created', MONEY_REQUEST_APPROVED: 'notification.money-request.approved', MONEY_REQUEST_DECLINED: 'notification.money-request.declined', + + // KYC Update events + KYC_APPROVED: 'notification.kyc.approved', + KYC_REJECTED: 'notification.kyc.rejected', + + // Card Status events + CARD_CREATED: 'notification.card.created', + CARD_BLOCKED: 'notification.card.blocked', + CARD_REISSUED: 'notification.card.reissued', + + // Profile Update events + PROFILE_UPDATED: 'notification.profile.updated', + + // System Alert events + MAINTENANCE_ALERT: 'notification.system.maintenance', + TRANSACTION_FAILED: 'notification.system.transaction-failed', + SUSPICIOUS_LOGIN: 'notification.system.suspicious-login', } as const; export type NotificationEventName = diff --git a/src/common/modules/notification/dtos/response/notifications.response.dto.ts b/src/common/modules/notification/dtos/response/notifications.response.dto.ts index 1f48591..56d4ef0 100644 --- a/src/common/modules/notification/dtos/response/notifications.response.dto.ts +++ b/src/common/modules/notification/dtos/response/notifications.response.dto.ts @@ -23,6 +23,13 @@ export class NotificationsResponseDto { this.title = notification.title; this.body = notification.message; this.status = notification.status!; - this.createdAt = notification.createdAt; + + // Use event timestamp from data if available, otherwise use notification creation time + // This ensures notifications show when the event occurred, not when notification was saved + if (notification.data?.timestamp) { + this.createdAt = new Date(notification.data.timestamp); + } else { + this.createdAt = notification.createdAt; + } } } diff --git a/src/common/modules/notification/enums/notification-scope.enum.ts b/src/common/modules/notification/enums/notification-scope.enum.ts index dc9bee0..13b300a 100644 --- a/src/common/modules/notification/enums/notification-scope.enum.ts +++ b/src/common/modules/notification/enums/notification-scope.enum.ts @@ -18,6 +18,23 @@ export enum NotificationScope { MONEY_REQUEST_CREATED = 'MONEY_REQUEST_CREATED', MONEY_REQUEST_APPROVED = 'MONEY_REQUEST_APPROVED', MONEY_REQUEST_DECLINED = 'MONEY_REQUEST_DECLINED', + + // KYC Update notifications + KYC_APPROVED = 'KYC_APPROVED', + KYC_REJECTED = 'KYC_REJECTED', + + // Card Status notifications + CARD_CREATED = 'CARD_CREATED', + CARD_BLOCKED = 'CARD_BLOCKED', + CARD_REISSUED = 'CARD_REISSUED', + + // Profile Update notifications + PROFILE_UPDATED = 'PROFILE_UPDATED', + + // System Alert notifications + MAINTENANCE_ALERT = 'MAINTENANCE_ALERT', + TRANSACTION_FAILED = 'TRANSACTION_FAILED', + SUSPICIOUS_LOGIN = 'SUSPICIOUS_LOGIN', } /** diff --git a/src/common/modules/notification/interfaces/notification-events.interface.ts b/src/common/modules/notification/interfaces/notification-events.interface.ts index d1e675d..e482851 100644 --- a/src/common/modules/notification/interfaces/notification-events.interface.ts +++ b/src/common/modules/notification/interfaces/notification-events.interface.ts @@ -1,6 +1,8 @@ import { Transaction } from '~/card/entities/transaction.entity'; import { Card } from '~/card/entities/card.entity'; import { MoneyRequest } from '~/money-request/entities/money-request.entity'; +import { Customer } from '~/customer/entities'; +import { KycStatus } from '~/customer/enums'; /** * Event payload for when a transaction is created @@ -61,3 +63,159 @@ export interface IMoneyRequestDeclinedEvent { /** When the event occurred */ timestamp: Date; } + +/** + * Event payload for when KYC is approved + * Used to notify users when their KYC verification is approved + */ +export interface IKycApprovedEvent { + /** The customer whose KYC was approved */ + customer: Customer; + + /** Previous KYC status */ + previousStatus: KycStatus; + + /** When the event occurred */ + timestamp: Date; +} + +/** + * Event payload for when KYC is rejected + * Used to notify users when their KYC verification is rejected + */ +export interface IKycRejectedEvent { + /** The customer whose KYC was rejected */ + customer: Customer; + + /** Previous KYC status */ + previousStatus: KycStatus; + + /** Rejection reason (if provided) */ + rejectionReason?: string; + + /** When the event occurred */ + timestamp: Date; +} + +/** + * Event payload for when a card is created + * Used to notify users when their card is successfully created + */ +export interface ICardCreatedEvent { + /** The card that was created */ + card: Card; + + /** When the event occurred */ + timestamp: Date; +} + +/** + * Event payload for when a card is blocked + * Used to notify users when their card is blocked + */ +export interface ICardBlockedEvent { + /** The card that was blocked */ + card: Card; + + /** Previous card status */ + previousStatus: string; + + /** Block reason/description */ + blockReason?: string; + + /** When the event occurred */ + timestamp: Date; +} + +/** + * Event payload for when a card is reissued + * Used to notify users when their card is reissued + */ +export interface ICardReissuedEvent { + /** The new card that was issued */ + card: Card; + + /** The old card that was replaced */ + oldCardId?: string; + + /** When the event occurred */ + timestamp: Date; +} + +/** + * Event payload for when a user profile is updated + * Used to notify users when their profile information is changed + */ +export interface IProfileUpdatedEvent { + /** The user whose profile was updated */ + user: any; + + /** Fields that were updated */ + updatedFields: string[]; + + /** When the event occurred */ + timestamp: Date; +} + +/** + * Event payload for system maintenance alerts + * Used to notify users about scheduled or unscheduled maintenance + */ +export interface IMaintenanceAlertEvent { + /** User ID to notify (null for broadcast to all users) */ + userId: string | null; + + /** Maintenance message */ + message: string; + + /** Scheduled start time */ + startTime?: Date; + + /** Scheduled end time */ + endTime?: Date; + + /** When the event occurred */ + timestamp: Date; +} + +/** + * Event payload for failed transaction alerts + * Used to notify users when a transaction fails + */ +export interface ITransactionFailedEvent { + /** The user whose transaction failed */ + userId: string; + + /** Transaction details */ + transactionId?: string; + + /** Failure reason */ + reason: string; + + /** Transaction amount (if applicable) */ + amount?: number; + + /** When the event occurred */ + timestamp: Date; +} + +/** + * Event payload for suspicious login detection + * Used to notify users about suspicious login attempts + */ +export interface ISuspiciousLoginEvent { + /** The user whose account had suspicious activity */ + userId: string; + + /** IP address of the login attempt */ + ipAddress?: string; + + /** Location of the login attempt */ + location?: string; + + /** Device information */ + device?: string; + + /** When the event occurred */ + timestamp: Date; +} diff --git a/src/common/modules/notification/listeners/card-notification.listener.ts b/src/common/modules/notification/listeners/card-notification.listener.ts new file mode 100644 index 0000000..4a8dc5e --- /dev/null +++ b/src/common/modules/notification/listeners/card-notification.listener.ts @@ -0,0 +1,162 @@ +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'; +import { ICardBlockedEvent, ICardCreatedEvent } from '../interfaces/notification-events.interface'; +import { NotificationScope } from '../enums/notification-scope.enum'; +import { User } from '~/user/entities'; +import { UserLocale } from '~/core/enums/user-locale.enum'; + +@Injectable() +export class CardNotificationListener { + private readonly logger = new Logger(CardNotificationListener.name); + + constructor( + private readonly notificationFactory: NotificationFactory, + private readonly userService: UserService, + private readonly i18n: I18nService, + ) {} + + @OnEvent(NOTIFICATION_EVENTS.CARD_CREATED) + async handleCardCreated(event: ICardCreatedEvent): Promise { + try { + const { card } = event; + const user = card?.customer?.user; + + if (!user) { + this.logger.warn(`No user found for card ${card.id}, skipping card created notification`); + return; + } + + const locale = this.getUserLocale(user); + const lastFourDigits = card.lastFourDigits; + + let title: string; + let message: string; + + try { + title = this.i18n.t('app.NOTIFICATION.CARD_CREATED_TITLE', { lang: locale }); + message = this.i18n.t('app.NOTIFICATION.CARD_CREATED_MESSAGE', { + lang: locale, + args: { + lastFourDigits: lastFourDigits, + }, + }); + } catch (i18nError: any) { + this.logger.error( + `[CardNotificationListener] i18n error for user ${user.id}: ${i18nError?.message || 'Unknown i18n error'}. Falling back to English.`, + i18nError?.stack + ); + title = 'Card Created'; + message = `Your card ending in ${lastFourDigits} has been created successfully. You can start using it once it's activated.`; + } + + this.logger.debug( + `Notifying user (user ${user.id}): Card created - ${lastFourDigits}` + ); + + await this.notificationFactory.send({ + userId: user.id, + title, + message, + scope: NotificationScope.CARD_CREATED, + preferences: this.getUserPreferences(user), + data: { + cardId: card.id, + lastFourDigits: lastFourDigits, + cardReference: card.cardReference, + status: card.status, + timestamp: event.timestamp.toISOString(), + action: 'VIEW_CARD', + }, + }); + + this.logger.log(`✅ Notified user ${user.id} about card creation`); + } catch (error: any) { + this.logger.error( + `Failed to process card created notification: ${error?.message || 'Unknown error'}`, + error?.stack + ); + } + } + + @OnEvent(NOTIFICATION_EVENTS.CARD_BLOCKED) + async handleCardBlocked(event: ICardBlockedEvent): Promise { + try { + const { card, blockReason } = event; + const user = card?.customer?.user; + + if (!user) { + this.logger.warn(`No user found for card ${card.id}, skipping card blocked notification`); + return; + } + + const locale = this.getUserLocale(user); + const lastFourDigits = card.lastFourDigits; + const reason = blockReason || 'Card has been blocked'; + + let title: string; + let message: string; + + try { + title = this.i18n.t('app.NOTIFICATION.CARD_BLOCKED_TITLE', { lang: locale }); + message = this.i18n.t('app.NOTIFICATION.CARD_BLOCKED_MESSAGE', { + lang: locale, + args: { + lastFourDigits: lastFourDigits, + reason: reason, + }, + }); + } catch (i18nError: any) { + this.logger.error( + `[CardNotificationListener] i18n error for user ${user.id}: ${i18nError?.message || 'Unknown i18n error'}. Falling back to English.`, + i18nError?.stack + ); + title = 'Card Blocked'; + message = `Your card ending in ${lastFourDigits} has been blocked. Reason: ${reason}. Please contact support for assistance.`; + } + + this.logger.debug( + `Notifying user (user ${user.id}): Card blocked - ${lastFourDigits}, reason: ${reason}` + ); + + await this.notificationFactory.send({ + userId: user.id, + title, + message, + scope: NotificationScope.CARD_BLOCKED, + preferences: this.getUserPreferences(user), + data: { + cardId: card.id, + lastFourDigits: lastFourDigits, + cardReference: card.cardReference, + status: card.status, + blockReason: reason, + timestamp: event.timestamp.toISOString(), + action: 'CONTACT_SUPPORT', + }, + }); + + this.logger.log(`✅ Notified user ${user.id} about card being blocked`); + } catch (error: any) { + this.logger.error( + `Failed to process card blocked notification: ${error?.message || 'Unknown error'}`, + error?.stack + ); + } + } + + private getUserPreferences(user: User): NotificationPreferences { + return { + isPushEnabled: user.isPushEnabled, + isEmailEnabled: user.isEmailEnabled, + isSmsEnabled: user.isSmsEnabled, + }; + } + + private getUserLocale(user: User): UserLocale { + return UserLocale.ENGLISH; + } +} diff --git a/src/common/modules/notification/listeners/index.ts b/src/common/modules/notification/listeners/index.ts index ff054b8..b929669 100644 --- a/src/common/modules/notification/listeners/index.ts +++ b/src/common/modules/notification/listeners/index.ts @@ -1,3 +1,7 @@ export * from './notification-created.listener'; export * from './transaction-notification.listener'; export * from './money-request-notification.listener'; +export * from './kyc-notification.listener'; +export * from './card-notification.listener'; +export * from './profile-notification.listener'; +export * from './system-alert-notification.listener'; \ No newline at end of file diff --git a/src/common/modules/notification/listeners/kyc-notification.listener.ts b/src/common/modules/notification/listeners/kyc-notification.listener.ts new file mode 100644 index 0000000..7767808 --- /dev/null +++ b/src/common/modules/notification/listeners/kyc-notification.listener.ts @@ -0,0 +1,233 @@ +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'; +import { + IKycApprovedEvent, + IKycRejectedEvent, +} from '../interfaces/notification-events.interface'; +import { NotificationScope } from '../enums/notification-scope.enum'; +import { User } from '~/user/entities'; +import { UserLocale } from '~/core/enums/user-locale.enum'; + +/** + * KycNotificationListener + * + * Handles notifications for KYC update events. + * Notifies users when their KYC verification is approved or rejected. + * + * Responsibilities: + * - Listen for KYC approval/rejection events + * - Determine notification recipient (the user whose KYC was updated) + * - Construct appropriate messages with rejection reason if applicable + * - Fetch user preferences + * - Call NotificationFactory to send + */ +@Injectable() +export class KycNotificationListener { + private readonly logger = new Logger(KycNotificationListener.name); + + constructor( + private readonly notificationFactory: NotificationFactory, + private readonly userService: UserService, + private readonly i18n: I18nService, + ) {} + + /** + * Handle KYC approved event + * Notifies user when their KYC verification is approved + */ + @OnEvent(NOTIFICATION_EVENTS.KYC_APPROVED) + async handleKycApproved(event: IKycApprovedEvent): Promise { + try { + const { customer } = event; + + this.logger.log( + `Processing KYC approved notification for customer ${customer.id}` + ); + + await this.notifyUserOfKycApproval(customer); + + this.logger.log( + `KYC approved notification processed successfully for customer ${customer.id}` + ); + } catch (error: any) { + this.logger.error( + `Failed to process KYC approved notification: ${error?.message || 'Unknown error'}`, + error?.stack + ); + } + } + + /** + * Handle KYC rejected event + * Notifies user when their KYC verification is rejected + */ + @OnEvent(NOTIFICATION_EVENTS.KYC_REJECTED) + async handleKycRejected(event: IKycRejectedEvent): Promise { + try { + const { customer, rejectionReason } = event; + + this.logger.log( + `Processing KYC rejected notification for customer ${customer.id} - Reason: ${rejectionReason || 'Not provided'}` + ); + + await this.notifyUserOfKycRejection(customer, rejectionReason); + + this.logger.log( + `KYC rejected notification processed successfully for customer ${customer.id}` + ); + } catch (error: any) { + this.logger.error( + `Failed to process KYC rejected notification: ${error?.message || 'Unknown error'}`, + error?.stack + ); + } + } + + /** + * Notify user when their KYC is approved + */ + private async notifyUserOfKycApproval(customer: any): Promise { + try { + const user = customer?.user; + if (!user) { + this.logger.warn(`No user found for customer ${customer.id}, skipping notification`); + return; + } + + const locale = this.getUserLocale(user); + + let title: string; + let message: string; + + try { + title = this.i18n.t('app.NOTIFICATION.KYC_APPROVED_TITLE', { lang: locale }); + message = this.i18n.t('app.NOTIFICATION.KYC_APPROVED_MESSAGE', { + lang: locale, + }); + } catch (i18nError: any) { + this.logger.error( + `[KycNotificationListener] i18n error for user ${user.id}: ${i18nError?.message || 'Unknown i18n error'}. Falling back to English.`, + i18nError?.stack + ); + title = 'KYC Verification Approved'; + message = 'Your KYC verification has been approved. You can now use all features of the app.'; + } + + this.logger.debug( + `Notifying user (user ${user.id}): KYC approved` + ); + + await this.notificationFactory.send({ + userId: user.id, + title, + message, + scope: NotificationScope.KYC_APPROVED, + preferences: this.getUserPreferences(user), + data: { + customerId: customer.id, + kycStatus: 'APPROVED', + timestamp: new Date().toISOString(), + type: 'KYC_APPROVED', + action: 'VIEW_PROFILE', + }, + }); + + this.logger.log(`✅ Notified user ${user.id} about KYC approval`); + } catch (error: any) { + this.logger.error( + `Failed to notify user of KYC approval: ${error?.message || 'Unknown error'}`, + error?.stack + ); + } + } + + /** + * Notify user when their KYC is rejected + */ + private async notifyUserOfKycRejection(customer: any, rejectionReason?: string): Promise { + try { + const user = customer?.user; + if (!user) { + this.logger.warn(`No user found for customer ${customer.id}, skipping notification`); + return; + } + + const locale = this.getUserLocale(user); + const reason = rejectionReason || customer.rejectionReason || 'KYC verification failed'; + + let title: string; + let message: string; + + try { + title = this.i18n.t('app.NOTIFICATION.KYC_REJECTED_TITLE', { lang: locale }); + message = this.i18n.t('app.NOTIFICATION.KYC_REJECTED_MESSAGE', { + lang: locale, + args: { + reason: reason, + }, + }); + } catch (i18nError: any) { + this.logger.error( + `[KycNotificationListener] i18n error for user ${user.id}: ${i18nError?.message || 'Unknown i18n error'}. Falling back to English.`, + i18nError?.stack + ); + title = 'KYC Verification Rejected'; + message = `Your KYC verification has been rejected. Reason: ${reason}. Please review your information and try again.`; + } + + this.logger.debug( + `Notifying user (user ${user.id}): KYC rejected - ${reason}` + ); + + await this.notificationFactory.send({ + userId: user.id, + title, + message, + scope: NotificationScope.KYC_REJECTED, + preferences: this.getUserPreferences(user), + data: { + customerId: customer.id, + kycStatus: 'REJECTED', + rejectionReason: reason, + timestamp: new Date().toISOString(), + type: 'KYC_REJECTED', + action: 'RETRY_KYC', + }, + }); + + this.logger.log(`✅ Notified user ${user.id} about KYC rejection`); + } catch (error: any) { + this.logger.error( + `Failed to notify user of KYC 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, + }; + } + + /** + * 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; + } +} diff --git a/src/common/modules/notification/listeners/money-request-notification.listener.ts b/src/common/modules/notification/listeners/money-request-notification.listener.ts index cc6359f..8d90f17 100644 --- a/src/common/modules/notification/listeners/money-request-notification.listener.ts +++ b/src/common/modules/notification/listeners/money-request-notification.listener.ts @@ -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'; @@ -10,6 +11,9 @@ import { } from '../interfaces/notification-events.interface'; import { NotificationScope } from '../enums/notification-scope.enum'; import { User } from '~/user/entities'; +import { MoneyRequest } from '~/money-request/entities/money-request.entity'; +import { UserLocale } from '~/core/enums/user-locale.enum'; +import { formatCurrencyAmount, getCurrency } from '~/common/utils/currency.util'; /** * MoneyRequestNotificationListener @@ -31,6 +35,7 @@ export class MoneyRequestNotificationListener { constructor( private readonly notificationFactory: NotificationFactory, private readonly userService: UserService, + private readonly i18n: I18nService, ) {} /** @@ -130,22 +135,26 @@ export class MoneyRequestNotificationListener { const childName = childUser?.firstName || 'Your child'; const amount = typeof moneyRequest.amount === 'string' ? parseFloat(moneyRequest.amount) : moneyRequest.amount; const reason = moneyRequest.reason || 'No reason provided'; + const accountCurrency = child?.customer?.cards?.[0]?.account?.currency; + const currency = getCurrency(accountCurrency, null, 'SAR'); + const formattedAmount = formatCurrencyAmount(amount, currency); this.logger.debug( - `Notifying parent (user ${parentUser.id}): ${childName} requested $${amount} - ${reason}` + `Notifying parent (user ${parentUser.id}): ${childName} requested ${formattedAmount} ${currency} - ${reason}` ); await this.notificationFactory.send({ userId: parentUser.id, title: 'Money Request', - message: `${childName} requested $${amount.toFixed(2)}. Reason: ${reason}`, + message: `${childName} requested ${formattedAmount} ${currency}. Reason: ${reason}`, scope: NotificationScope.MONEY_REQUEST_CREATED, preferences: this.getUserPreferences(parentUser), data: { moneyRequestId: moneyRequest.id, childId: childUser?.id, childName: childName, - amount: amount.toString(), + amount: formattedAmount, + currency: currency, reason: reason, timestamp: moneyRequest.createdAt.toISOString(), type: 'MONEY_REQUEST', @@ -176,20 +185,24 @@ export class MoneyRequestNotificationListener { } const amount = typeof moneyRequest.amount === 'string' ? parseFloat(moneyRequest.amount) : moneyRequest.amount; + const accountCurrency = child?.customer?.cards?.[0]?.account?.currency; + const currency = getCurrency(accountCurrency, null, 'SAR'); + const formattedAmount = formatCurrencyAmount(amount, currency); this.logger.debug( - `Notifying child (user ${childUser.id}): Money request of $${amount} was approved` + `Notifying child (user ${childUser.id}): Money request of ${formattedAmount} ${currency} 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.`, + message: `Your request for ${formattedAmount} ${currency} 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(), + amount: formattedAmount, + currency: currency, timestamp: moneyRequest.updatedAt.toISOString(), type: 'MONEY_REQUEST_APPROVED', action: 'VIEW_MONEY_REQUEST', @@ -219,21 +232,48 @@ export class MoneyRequestNotificationListener { } const amount = typeof moneyRequest.amount === 'string' ? parseFloat(moneyRequest.amount) : moneyRequest.amount; + const accountCurrency = child?.customer?.cards?.[0]?.account?.currency; + const currency = getCurrency(accountCurrency, null, 'SAR'); + const formattedAmount = formatCurrencyAmount(amount, currency); const reason = rejectionReason || 'No reason provided'; + const locale = this.getUserLocale(childUser); this.logger.debug( - `Notifying child (user ${childUser.id}): Money request of $${amount} was declined` + `Notifying child (user ${childUser.id}): Money request of ${formattedAmount} ${currency} was declined` ); + let title: string; + let message: string; + + try { + title = this.i18n.t('app.NOTIFICATION.MONEY_REQUEST_DECLINED_TITLE', { lang: locale }); + message = this.i18n.t('app.NOTIFICATION.MONEY_REQUEST_DECLINED_MESSAGE', { + lang: locale, + args: { + amount: formattedAmount, + currency: currency, + reason: reason, + }, + }); + } catch (i18nError: any) { + this.logger.error( + `[MoneyRequestNotificationListener] i18n error for child ${childUser.id}: ${i18nError?.message || 'Unknown i18n error'}. Falling back to English.`, + i18nError?.stack + ); + title = 'Money Request Declined'; + message = `Your request for ${formattedAmount} ${currency} has been declined. Reason: ${reason}`; + } + await this.notificationFactory.send({ userId: childUser.id, - title: 'Money Request Declined', - message: `Your request for $${amount.toFixed(2)} has been declined. Reason: ${reason}`, + title, + message, scope: NotificationScope.MONEY_REQUEST_DECLINED, preferences: this.getUserPreferences(childUser), data: { moneyRequestId: moneyRequest.id, - amount: amount.toString(), + amount: formattedAmount, + currency: currency, rejectionReason: reason, timestamp: moneyRequest.updatedAt.toISOString(), type: 'MONEY_REQUEST_DECLINED', @@ -261,4 +301,10 @@ export class MoneyRequestNotificationListener { isSmsEnabled: user.isSmsEnabled, }; } + + 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; + } } diff --git a/src/common/modules/notification/listeners/notification-created.listener.ts b/src/common/modules/notification/listeners/notification-created.listener.ts index 1040ed3..b950064 100644 --- a/src/common/modules/notification/listeners/notification-created.listener.ts +++ b/src/common/modules/notification/listeners/notification-created.listener.ts @@ -6,6 +6,7 @@ import { EventType, NotificationChannel, NotificationScope } from '~/common/modu import { FirebaseService, TwilioService } from '~/common/modules/notification/services'; import { IEventInterface } from '~/common/redis/interface'; import { DeviceService } from '~/user/services'; +import { UserService } from '~/user/services/user.service'; @Injectable() export class NotificationCreatedListener { @@ -16,6 +17,7 @@ export class NotificationCreatedListener { private readonly deviceService: DeviceService, private readonly mailerService: MailerService, private readonly firebaseService: FirebaseService, + private readonly userService: UserService, ) {} /** @@ -60,26 +62,48 @@ export class NotificationCreatedListener { body: string, data?: Record, ) { - this.logger.log(`Sending push notification to user ${userId}`); - const tokens = await this.deviceService.getTokens(userId); + try { + // Check if user has push notifications enabled + const user = await this.userService.findUser({ id: userId }); + if (!user) { + this.logger.warn(`User ${userId} not found, skipping push notification`); + return; + } - if (!tokens.length) { - this.logger.log(`No device tokens found for user ${userId}, but notification was created in the DB.`); - return; + if (!user.isPushEnabled) { + this.logger.log( + `Push notifications disabled for user ${userId}, notification saved to DB but push not sent` + ); + return; + } + + this.logger.log(`Sending push notification to user ${userId}`); + const tokens = await this.deviceService.getTokens(userId); + + if (!tokens.length) { + this.logger.log(`No device tokens found for user ${userId}, but notification was created in the DB.`); + return; + } + + // Convert data to string values (Firebase requires string values in data payload) + const stringData: Record | undefined = data + ? Object.entries(data).reduce( + (acc, [key, value]) => { + acc[key] = String(value); + return acc; + }, + {} as Record, + ) + : undefined; + + return this.firebaseService.sendNotification(tokens, title, body, stringData); + } catch (error: any) { + this.logger.error( + `Failed to send push notification to user ${userId}: ${error?.message || 'Unknown error'}`, + error?.stack + ); + // Don't throw - notification is already saved to DB } - - // Convert data to string values (Firebase requires string values in data payload) - const stringData: Record | undefined = data - ? Object.entries(data).reduce( - (acc, [key, value]) => { - acc[key] = String(value); - return acc; - }, - {} as Record, - ) - : undefined; - - return this.firebaseService.sendNotification(tokens, title, body, stringData); } private async sendSMS(to: string, body: string) { diff --git a/src/common/modules/notification/listeners/profile-notification.listener.ts b/src/common/modules/notification/listeners/profile-notification.listener.ts new file mode 100644 index 0000000..ea65b8a --- /dev/null +++ b/src/common/modules/notification/listeners/profile-notification.listener.ts @@ -0,0 +1,149 @@ +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'; +import { IProfileUpdatedEvent } from '../interfaces/notification-events.interface'; +import { NotificationScope } from '../enums/notification-scope.enum'; +import { User } from '~/user/entities'; +import { UserLocale } from '~/core/enums/user-locale.enum'; + +@Injectable() +export class ProfileNotificationListener { + private readonly logger = new Logger(ProfileNotificationListener.name); + + constructor( + private readonly notificationFactory: NotificationFactory, + private readonly userService: UserService, + private readonly i18n: I18nService, + ) {} + + @OnEvent(NOTIFICATION_EVENTS.PROFILE_UPDATED) + async handleProfileUpdated(event: IProfileUpdatedEvent): Promise { + try { + const { user, updatedFields } = event; + + this.logger.log( + `Processing profile updated notification for user ${user.id} - Updated fields: ${updatedFields.join(', ')}` + ); + + await this.notifyUserOfProfileUpdate(user, updatedFields); + + this.logger.log( + `Profile updated notification processed successfully for user ${user.id}` + ); + } catch (error: any) { + this.logger.error( + `Failed to process profile updated notification: ${error?.message || 'Unknown error'}`, + error?.stack + ); + } + } + + private async notifyUserOfProfileUpdate(user: any, updatedFields: string[]): Promise { + try { + if (!user) { + this.logger.warn(`No user found, skipping profile update notification`); + return; + } + + const locale = this.getUserLocale(user); + const isEmailUpdate = updatedFields.includes('email'); + const isPasswordUpdate = updatedFields.includes('password'); + const isProfilePictureUpdate = updatedFields.includes('profilePictureId'); + const isNameUpdate = updatedFields.includes('firstName') || updatedFields.includes('lastName'); + + let title: string; + let message: string; + + try { + if (isEmailUpdate) { + title = this.i18n.t('app.NOTIFICATION.PROFILE_EMAIL_UPDATED_TITLE', { lang: locale }); + message = this.i18n.t('app.NOTIFICATION.PROFILE_EMAIL_UPDATED_MESSAGE', { + lang: locale, + args: { + email: user.email || 'your email', + }, + }); + } else if (isPasswordUpdate) { + title = this.i18n.t('app.NOTIFICATION.PROFILE_PASSWORD_UPDATED_TITLE', { lang: locale }); + message = this.i18n.t('app.NOTIFICATION.PROFILE_PASSWORD_UPDATED_MESSAGE', { + lang: locale, + }); + } else if (isProfilePictureUpdate) { + title = this.i18n.t('app.NOTIFICATION.PROFILE_PICTURE_UPDATED_TITLE', { lang: locale }); + message = this.i18n.t('app.NOTIFICATION.PROFILE_PICTURE_UPDATED_MESSAGE', { + lang: locale, + }); + } else if (isNameUpdate) { + title = this.i18n.t('app.NOTIFICATION.PROFILE_NAME_UPDATED_TITLE', { lang: locale }); + message = this.i18n.t('app.NOTIFICATION.PROFILE_NAME_UPDATED_MESSAGE', { + lang: locale, + }); + } else { + title = this.i18n.t('app.NOTIFICATION.PROFILE_UPDATED_TITLE', { lang: locale }); + message = this.i18n.t('app.NOTIFICATION.PROFILE_UPDATED_MESSAGE', { + lang: locale, + args: { + fields: updatedFields.join(', '), + }, + }); + } + } catch (i18nError: any) { + this.logger.error( + `[ProfileNotificationListener] i18n error for user ${user.id}: ${i18nError?.message || 'Unknown i18n error'}. Falling back to English.`, + i18nError?.stack + ); + + if (isEmailUpdate) { + title = 'Email Updated'; + message = `Your email has been updated to ${user.email || 'a new email'}. Please verify your new email address.`; + } else if (isPasswordUpdate) { + title = 'Password Updated'; + message = 'Your password has been successfully updated. If you did not make this change, please contact support immediately.'; + } else { + title = 'Profile Updated'; + message = `Your profile has been updated. Changes: ${updatedFields.join(', ')}`; + } + } + + this.logger.debug( + `Notifying user (user ${user.id}): Profile updated - ${updatedFields.join(', ')}` + ); + + await this.notificationFactory.send({ + userId: user.id, + title, + message, + scope: NotificationScope.PROFILE_UPDATED, + preferences: this.getUserPreferences(user), + data: { + updatedFields: updatedFields, + timestamp: new Date().toISOString(), + type: 'PROFILE_UPDATE', + action: 'VIEW_PROFILE', + }, + }); + + this.logger.log(`✅ Notified user ${user.id} about profile update`); + } catch (error: any) { + this.logger.error( + `Failed to notify user of profile update: ${error?.message || 'Unknown error'}`, + error?.stack + ); + } + } + + private getUserPreferences(user: User): NotificationPreferences { + return { + isPushEnabled: user.isPushEnabled, + isEmailEnabled: user.isEmailEnabled, + isSmsEnabled: user.isSmsEnabled, + }; + } + + private getUserLocale(user: User): UserLocale { + return UserLocale.ENGLISH; + } +} diff --git a/src/common/modules/notification/listeners/system-alert-notification.listener.ts b/src/common/modules/notification/listeners/system-alert-notification.listener.ts new file mode 100644 index 0000000..38c94ef --- /dev/null +++ b/src/common/modules/notification/listeners/system-alert-notification.listener.ts @@ -0,0 +1,282 @@ +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'; +import { + IMaintenanceAlertEvent, + ISuspiciousLoginEvent, + ITransactionFailedEvent, +} from '../interfaces/notification-events.interface'; +import { NotificationScope } from '../enums/notification-scope.enum'; +import { User } from '~/user/entities'; +import { UserLocale } from '~/core/enums/user-locale.enum'; + +@Injectable() +export class SystemAlertNotificationListener { + private readonly logger = new Logger(SystemAlertNotificationListener.name); + + constructor( + private readonly notificationFactory: NotificationFactory, + private readonly userService: UserService, + private readonly i18n: I18nService, + ) {} + + @OnEvent(NOTIFICATION_EVENTS.MAINTENANCE_ALERT) + async handleMaintenanceAlert(event: IMaintenanceAlertEvent): Promise { + try { + const { userId, message, startTime, endTime } = event; + + this.logger.log( + `Processing maintenance alert notification - User: ${userId || 'ALL'}, Message: ${message}` + ); + + if (userId) { + await this.notifyUserOfMaintenance(userId, message, startTime, endTime); + } else { + this.logger.warn('Broadcast maintenance alerts to all users not yet implemented'); + } + + this.logger.log(`Maintenance alert notification processed successfully`); + } catch (error: any) { + this.logger.error( + `Failed to process maintenance alert notification: ${error?.message || 'Unknown error'}`, + error?.stack + ); + } + } + + @OnEvent(NOTIFICATION_EVENTS.TRANSACTION_FAILED) + async handleTransactionFailed(event: ITransactionFailedEvent): Promise { + try { + const { userId, transactionId, reason, amount } = event; + + this.logger.log( + `Processing transaction failed notification for user ${userId} - Transaction: ${transactionId}, Reason: ${reason}` + ); + + await this.notifyUserOfTransactionFailure(userId, transactionId, reason, amount); + + this.logger.log(`Transaction failed notification processed successfully for user ${userId}`); + } catch (error: any) { + this.logger.error( + `Failed to process transaction failed notification: ${error?.message || 'Unknown error'}`, + error?.stack + ); + } + } + + @OnEvent(NOTIFICATION_EVENTS.SUSPICIOUS_LOGIN) + async handleSuspiciousLogin(event: ISuspiciousLoginEvent): Promise { + try { + const { userId, ipAddress, location, device } = event; + + this.logger.log( + `Processing suspicious login notification for user ${userId} - IP: ${ipAddress}, Location: ${location}` + ); + + await this.notifyUserOfSuspiciousLogin(userId, ipAddress, location, device); + + this.logger.log(`Suspicious login notification processed successfully for user ${userId}`); + } catch (error: any) { + this.logger.error( + `Failed to process suspicious login notification: ${error?.message || 'Unknown error'}`, + error?.stack + ); + } + } + + private async notifyUserOfMaintenance( + userId: string, + message: string, + startTime?: Date, + endTime?: Date, + ): Promise { + try { + const user = await this.userService.findUserOrThrow({ id: userId }); + const locale = this.getUserLocale(user); + + let title: string; + let notificationMessage: string; + + try { + title = this.i18n.t('app.NOTIFICATION.MAINTENANCE_ALERT_TITLE', { lang: locale }); + notificationMessage = this.i18n.t('app.NOTIFICATION.MAINTENANCE_ALERT_MESSAGE', { + lang: locale, + args: { + message: message, + startTime: startTime ? startTime.toLocaleString() : '', + endTime: endTime ? endTime.toLocaleString() : '', + }, + }); + } catch (i18nError: any) { + this.logger.error( + `[SystemAlertNotificationListener] i18n error for user ${userId}: ${i18nError?.message || 'Unknown i18n error'}. Falling back to English.`, + i18nError?.stack + ); + title = 'Scheduled Maintenance'; + notificationMessage = message || 'The system will be under maintenance. Please check back later.'; + if (startTime && endTime) { + notificationMessage += ` Scheduled from ${startTime.toLocaleString()} to ${endTime.toLocaleString()}.`; + } + } + + await this.notificationFactory.send({ + userId: user.id, + title, + message: notificationMessage, + scope: NotificationScope.MAINTENANCE_ALERT, + preferences: this.getUserPreferences(user), + data: { + message: message, + startTime: startTime?.toISOString(), + endTime: endTime?.toISOString(), + timestamp: new Date().toISOString(), + type: 'MAINTENANCE', + action: 'VIEW_STATUS', + }, + }); + + this.logger.log(`✅ Notified user ${userId} about maintenance`); + } catch (error: any) { + this.logger.error( + `Failed to notify user of maintenance: ${error?.message || 'Unknown error'}`, + error?.stack + ); + } + } + + private async notifyUserOfTransactionFailure( + userId: string, + transactionId: string | undefined, + reason: string, + amount?: number, + ): Promise { + try { + const user = await this.userService.findUserOrThrow({ id: userId }); + const locale = this.getUserLocale(user); + + let title: string; + let message: string; + + try { + title = this.i18n.t('app.NOTIFICATION.TRANSACTION_FAILED_TITLE', { lang: locale }); + message = this.i18n.t('app.NOTIFICATION.TRANSACTION_FAILED_MESSAGE', { + lang: locale, + args: { + reason: reason, + amount: amount ? amount.toString() : '', + }, + }); + } catch (i18nError: any) { + this.logger.error( + `[SystemAlertNotificationListener] i18n error for user ${userId}: ${i18nError?.message || 'Unknown i18n error'}. Falling back to English.`, + i18nError?.stack + ); + title = 'Transaction Failed'; + message = `Your transaction could not be completed. Reason: ${reason}.`; + if (amount) { + message += ` Amount: ${amount}`; + } + message += ' Please try again or contact support if the issue persists.'; + } + + await this.notificationFactory.send({ + userId: user.id, + title, + message, + scope: NotificationScope.TRANSACTION_FAILED, + preferences: this.getUserPreferences(user), + data: { + transactionId: transactionId, + reason: reason, + amount: amount, + timestamp: new Date().toISOString(), + type: 'TRANSACTION_FAILED', + action: 'RETRY_TRANSACTION', + }, + }); + + this.logger.log(`✅ Notified user ${userId} about failed transaction`); + } catch (error: any) { + this.logger.error( + `Failed to notify user of transaction failure: ${error?.message || 'Unknown error'}`, + error?.stack + ); + } + } + + private async notifyUserOfSuspiciousLogin( + userId: string, + ipAddress?: string, + location?: string, + device?: string, + ): Promise { + try { + const user = await this.userService.findUserOrThrow({ id: userId }); + const locale = this.getUserLocale(user); + + let title: string; + let message: string; + + try { + title = this.i18n.t('app.NOTIFICATION.SUSPICIOUS_LOGIN_TITLE', { lang: locale }); + message = this.i18n.t('app.NOTIFICATION.SUSPICIOUS_LOGIN_MESSAGE', { + lang: locale, + args: { + location: location || 'unknown location', + device: device || 'unknown device', + ipAddress: ipAddress || 'unknown IP', + }, + }); + } catch (i18nError: any) { + this.logger.error( + `[SystemAlertNotificationListener] i18n error for user ${userId}: ${i18nError?.message || 'Unknown i18n error'}. Falling back to English.`, + i18nError?.stack + ); + title = 'Suspicious Login Detected'; + message = `We detected a login attempt from ${location || 'an unknown location'} (${ipAddress || 'unknown IP'})`; + if (device) { + message += ` using ${device}`; + } + message += '. If this was not you, please change your password immediately and contact support.'; + } + + await this.notificationFactory.send({ + userId: user.id, + title, + message, + scope: NotificationScope.SUSPICIOUS_LOGIN, + preferences: this.getUserPreferences(user), + data: { + ipAddress: ipAddress, + location: location, + device: device, + timestamp: new Date().toISOString(), + type: 'SUSPICIOUS_LOGIN', + action: 'CHANGE_PASSWORD', + }, + }); + + this.logger.log(`✅ Notified user ${userId} about suspicious login`); + } catch (error: any) { + this.logger.error( + `Failed to notify user of suspicious login: ${error?.message || 'Unknown error'}`, + error?.stack + ); + } + } + + private getUserPreferences(user: User): NotificationPreferences { + return { + isPushEnabled: user.isPushEnabled, + isEmailEnabled: user.isEmailEnabled, + isSmsEnabled: user.isSmsEnabled, + }; + } + + private getUserLocale(user: User): UserLocale { + return UserLocale.ENGLISH; + } +} diff --git a/src/common/modules/notification/listeners/transaction-notification.listener.ts b/src/common/modules/notification/listeners/transaction-notification.listener.ts index 4691316..dc1d4ea 100644 --- a/src/common/modules/notification/listeners/transaction-notification.listener.ts +++ b/src/common/modules/notification/listeners/transaction-notification.listener.ts @@ -10,6 +10,7 @@ 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'; +import { formatCurrencyAmount, getCurrency, numericToCurrencyCode } from '~/common/utils/currency.util'; /** * TransactionNotificationListener @@ -98,7 +99,13 @@ export class TransactionNotificationListener { const amount = transaction.transactionAmount; const merchant = transaction.merchantName || 'merchant'; const balance = card.account?.balance || 0; - const currency = card.account?.currency || transaction.transactionCurrency || 'SAR'; + const currency = getCurrency( + card.account?.currency, + transaction.transactionCurrency, + 'SAR' + ); + const formattedAmount = formatCurrencyAmount(amount, currency); + const formattedBalance = formatCurrencyAmount(balance, currency); let title: string; let message: string; @@ -108,32 +115,32 @@ export class TransactionNotificationListener { ? 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(), - }, - }); + message = isTopUp + ? this.i18n.t('app.NOTIFICATION.CHILD_TOP_UP_MESSAGE', { + lang: locale, + args: { + amount: formattedAmount, + currency: currency, + balance: formattedBalance, + }, + }) + : this.i18n.t('app.NOTIFICATION.CHILD_SPENDING_MESSAGE', { + lang: locale, + args: { + amount: formattedAmount, + currency: currency, + merchant: merchant, + balance: formattedBalance, + }, + }); } 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}`; + title = isTopUp ? 'Card Topped Up' : 'Purchase Successful'; + message = isTopUp + ? `You received ${formattedAmount} ${currency}. Total balance: ${formattedBalance} ${currency}` + : `You spent ${formattedAmount} ${currency} at ${merchant}. Balance: ${formattedBalance} ${currency}`; } this.logger.debug( @@ -146,13 +153,13 @@ export class TransactionNotificationListener { message, scope, preferences: this.getUserPreferences(user), - data: { - transactionId: transaction.id, - amount: amount.toString(), - currency: currency, - merchant: merchant, - merchantCategory: transaction.merchantCategoryCode || 'OTHER', - balance: balance.toString(), + data: { + transactionId: transaction.id, + amount: formattedAmount, + currency: currency, + merchant: merchant, + merchantCategory: transaction.merchantCategoryCode || 'OTHER', + balance: formattedBalance, timestamp: transaction.transactionDate.toISOString(), type: isTopUp ? 'TOP_UP' : 'SPENDING', action: 'OPEN_TRANSACTION', @@ -191,10 +198,16 @@ export class TransactionNotificationListener { const amount = transaction.transactionAmount; const merchant = transaction.merchantName || 'a merchant'; const balance = card.account?.balance || 0; - const currency = card.account?.currency || transaction.transactionCurrency || 'SAR'; + const currency = getCurrency( + card.account?.currency, + transaction.transactionCurrency, + 'SAR' + ); + const formattedAmount = formatCurrencyAmount(amount, currency); + const formattedBalance = formatCurrencyAmount(balance, currency); this.logger.debug( - `Notifying parent (user ${parentUser.id}): ${childName} spent ${amount} ${currency} at ${merchant}` + `Notifying parent (user ${parentUser.id}): ${childName} spent ${formattedAmount} ${currency} at ${merchant}` ); let title: string; @@ -202,21 +215,21 @@ export class TransactionNotificationListener { 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(), - }, - }); + message = this.i18n.t('app.NOTIFICATION.PARENT_SPENDING_MESSAGE', { + lang: locale, + args: { + childName: childName, + amount: formattedAmount, + currency: currency, + merchant: merchant, + balance: formattedBalance, + }, + }); } 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}`; + message = `${childName} spent ${formattedAmount} ${currency} at ${merchant}. Balance: ${formattedBalance} ${currency}`; } await this.notificationFactory.send({ @@ -271,10 +284,16 @@ export class TransactionNotificationListener { const childName = childUser?.firstName || defaultChildName; const amount = transaction.transactionAmount; const balance = card.account?.balance || 0; - const currency = card.account?.currency || transaction.transactionCurrency || 'SAR'; + const currency = getCurrency( + card.account?.currency, + transaction.transactionCurrency, + 'SAR' + ); + const formattedAmount = formatCurrencyAmount(amount, currency); + const formattedBalance = formatCurrencyAmount(balance, currency); this.logger.debug( - `Notifying parent (user ${parentUser.id}): Transferred ${amount} ${currency} to ${childName}` + `Notifying parent (user ${parentUser.id}): Transferred ${formattedAmount} ${currency} to ${childName}` ); let title: string; @@ -295,7 +314,7 @@ export class TransactionNotificationListener { 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}`; + message = `You transferred ${formattedAmount} ${currency} to ${childName}. Balance: ${formattedBalance} ${currency}`; } await this.notificationFactory.send({ diff --git a/src/common/modules/notification/notification.module.ts b/src/common/modules/notification/notification.module.ts index 733a1d2..1ab1708 100644 --- a/src/common/modules/notification/notification.module.ts +++ b/src/common/modules/notification/notification.module.ts @@ -9,8 +9,12 @@ import { UserModule } from '~/user/user.module'; import { NotificationsController } from './controllers'; import { Notification } from './entities'; import { + CardNotificationListener, + KycNotificationListener, MoneyRequestNotificationListener, NotificationCreatedListener, + ProfileNotificationListener, + SystemAlertNotificationListener, TransactionNotificationListener, } from './listeners'; import { NotificationsRepository } from './repositories'; @@ -40,6 +44,10 @@ import { MessagingSystemFactory, RedisPubSubMessagingService } from './services/ NotificationCreatedListener, TransactionNotificationListener, MoneyRequestNotificationListener, + KycNotificationListener, + CardNotificationListener, + ProfileNotificationListener, + SystemAlertNotificationListener, RedisPubSubMessagingService, MessagingSystemFactory, ], diff --git a/src/common/modules/notification/services/notification-factory.service.ts b/src/common/modules/notification/services/notification-factory.service.ts index 713c60a..3fbdab2 100644 --- a/src/common/modules/notification/services/notification-factory.service.ts +++ b/src/common/modules/notification/services/notification-factory.service.ts @@ -83,6 +83,10 @@ export class NotificationFactory { * Send a notification to a user * Routes to enabled channels based on provided preferences * + * Note: Notifications are always saved to the database (via PUSH channel) + * for history/audit purposes, even if push notifications are disabled. + * The preferences only control whether push/email/SMS are actually sent. + * * @param payload - Notification payload including preferences */ async send(payload: NotificationPayload): Promise { @@ -97,17 +101,23 @@ export class NotificationFactory { const promises: Promise[] = []; - if (preferences.isPushEnabled) { - this.logger.debug(`Routing to PUSH channel for user ${payload.userId}`); - promises.push( - this.sendToChannel(payload, NotificationChannel.PUSH) - ); - } + // Always create notification record in database (via PUSH channel for storage) + // This ensures notifications are saved for history, even if push is disabled + this.logger.debug(`Creating notification record for user ${payload.userId}`); + promises.push( + this.sendToChannel(payload, NotificationChannel.PUSH) + ); + + // Only send via additional channels if enabled + // Note: PUSH channel is already added above for database storage + // The actual push delivery will check preferences in FirebaseService await Promise.all(promises); + const activeChannels = preferences.isPushEnabled ? 1 : 0; this.logger.log( - `Notification sent to user ${payload.userId} via ${promises.length} channel(s)` + `Notification sent to user ${payload.userId} via ${activeChannels} active channel(s) ` + + `(saved to database regardless of preferences)` ); } catch (error: any) { this.logger.error( diff --git a/src/common/utils/currency.util.ts b/src/common/utils/currency.util.ts new file mode 100644 index 0000000..096a176 --- /dev/null +++ b/src/common/utils/currency.util.ts @@ -0,0 +1,104 @@ +/** + * Currency utility functions + * Handles currency code mapping and formatting + */ + +/** + * ISO 4217 numeric currency codes to ISO currency code mapping + * Common codes used in the system: + * - 682: SAR (Saudi Riyal) + * - 900: USD (US Dollar) - if used + * - 784: AED (UAE Dirham) + * - 414: KWD (Kuwaiti Dinar) + * - 512: OMR (Omani Rial) + * - 048: BHD (Bahraini Dinar) + * - 400: JOD (Jordanian Dinar) + */ +export const NUMERIC_TO_CURRENCY_CODE: Record = { + '682': 'SAR', + '900': 'USD', + '784': 'AED', + '414': 'KWD', + '512': 'OMR', + '048': 'BHD', + '400': 'JOD', + '586': 'PKR', +}; + +/** + * Currency decimal places mapping + * ISO 4217 standard decimal places for each currency + */ +export const CURRENCY_DECIMAL_PLACES: Record = { + 'SAR': 2, // Saudi Riyal + 'USD': 2, // US Dollar + 'AED': 2, // UAE Dirham + 'KWD': 3, // Kuwaiti Dinar + 'OMR': 3, // Omani Rial + 'BHD': 3, // Bahraini Dinar + 'JOD': 3, // Jordanian Dinar + 'PKR': 2, // Pakistani Rupee + 'JPY': 0, // Japanese Yen (if used) + 'KRW': 0, // South Korean Won (if used) +}; + +/** + * Convert numeric currency code to ISO currency code + * @param numericCode - Numeric currency code (e.g., '682') + * @returns ISO currency code (e.g., 'SAR') or the original code if not found + */ +export function numericToCurrencyCode(numericCode: string | null | undefined): string { + if (!numericCode) { + return 'SAR'; // Default fallback + } + + // If already an ISO code (3 letters), return as is + if (/^[A-Z]{3}$/.test(numericCode)) { + return numericCode; + } + + // Map numeric code to ISO code + return NUMERIC_TO_CURRENCY_CODE[numericCode] || numericCode; +} + +/** + * Format amount based on currency decimal places + * @param amount - Amount to format (number or string) + * @param currency - ISO currency code (e.g., 'SAR', 'KWD') + * @returns Formatted amount string + */ +export function formatCurrencyAmount(amount: number | string, currency: string): string { + const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount; + + if (isNaN(numAmount)) { + return '0'; + } + + const decimalPlaces = CURRENCY_DECIMAL_PLACES[currency] ?? 2; + return numAmount.toFixed(decimalPlaces); +} + +/** + * Get currency from account or transaction, with fallback + * @param accountCurrency - Currency from account entity + * @param transactionCurrency - Currency from transaction entity (may be numeric) + * @param fallback - Fallback currency (default: 'SAR') + * @returns ISO currency code + */ +export function getCurrency( + accountCurrency?: string | null, + transactionCurrency?: string | null, + fallback: string = 'SAR' +): string { + // Prefer account currency (already in ISO format) + if (accountCurrency) { + return accountCurrency; + } + + // Convert transaction currency (may be numeric) + if (transactionCurrency) { + return numericToCurrencyCode(transactionCurrency); + } + + return fallback; +} diff --git a/src/customer/services/customer.service.ts b/src/customer/services/customer.service.ts index d47ab97..383fe2c 100644 --- a/src/customer/services/customer.service.ts +++ b/src/customer/services/customer.service.ts @@ -1,10 +1,16 @@ import { BadRequestException, ConflictException, forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import moment from 'moment'; import { Transactional } from 'typeorm-transactional'; import { CountryIso } from '~/common/enums'; import { NumericToCountryIso } from '~/common/mappers'; import { KycWebhookRequest } from '~/common/modules/neoleap/dtos/requests'; import { NeoLeapService } from '~/common/modules/neoleap/services'; +import { NOTIFICATION_EVENTS } from '~/common/modules/notification/constants/event-names.constant'; +import { + IKycApprovedEvent, + IKycRejectedEvent, +} from '~/common/modules/notification/interfaces/notification-events.interface'; import { GuardianService } from '~/guardian/services'; import { CreateJuniorRequestDto } from '~/junior/dtos/request'; import { User } from '~/user/entities'; @@ -23,6 +29,7 @@ export class CustomerService { private readonly guardianService: GuardianService, @Inject(forwardRef(() => NeoLeapService)) private readonly neoleapService: NeoLeapService, private readonly metadataService: MetadataService, + private readonly eventEmitter: EventEmitter2, ) {} async updateCustomer(userId: string, data: Partial): Promise { @@ -151,6 +158,7 @@ export class CustomerService { } const customer = await this.findCustomerById(transaction.customerId); + const previousStatus = customer.kycStatus; // Update transaction record await this.kycTransactionRepo.updateByStateId(body.stateId, { @@ -165,8 +173,32 @@ export class CustomerService { await this.customerRepository.updateCustomer(customer.id, { kycStatus, neoleapExternalCustomerId: body.entity.externalId, + rejectionReason: kycStatus === KycStatus.REJECTED ? 'KYC verification failed' : null, }); + // Reload customer with updated data + const updatedCustomer = await this.findCustomerById(customer.id); + + // Emit notification event + if (kycStatus === KycStatus.APPROVED) { + const event: IKycApprovedEvent = { + customer: updatedCustomer, + previousStatus, + timestamp: new Date(), + }; + this.eventEmitter.emit(NOTIFICATION_EVENTS.KYC_APPROVED, event); + this.logger.log(`Emitted KYC_APPROVED event for customer ${customer.id}`); + } else { + const event: IKycRejectedEvent = { + customer: updatedCustomer, + previousStatus, + rejectionReason: updatedCustomer.rejectionReason || 'KYC verification failed', + timestamp: new Date(), + }; + this.eventEmitter.emit(NOTIFICATION_EVENTS.KYC_REJECTED, event); + this.logger.log(`Emitted KYC_REJECTED event for customer ${customer.id}`); + } + this.logger.log(`KYC updated successfully for customer ${customer.id}, status: ${body.status}, externalId: ${body.entity.externalId}`); } diff --git a/src/i18n/ar/app.json b/src/i18n/ar/app.json index a48c782..1aa02f2 100644 --- a/src/i18n/ar/app.json +++ b/src/i18n/ar/app.json @@ -120,6 +120,36 @@ "PARENT_TOP_UP_MESSAGE": "لقد قمت بتحويل {amount} {currency} إلى {childName}. الرصيد: {balance} {currency}", "PARENT_SPENDING_TITLE": "تنبيه إنفاق الطفل", "PARENT_SPENDING_MESSAGE": "أنفق {childName} {amount} {currency} في {merchant}. الرصيد: {balance} {currency}", - "YOUR_CHILD": "طفلك" + "YOUR_CHILD": "طفلك", + "MONEY_REQUEST_CREATED_TITLE": "طلب مال", + "MONEY_REQUEST_CREATED_MESSAGE": "طلب {childName} مبلغ {amount} {currency}. السبب: {reason}", + "MONEY_REQUEST_APPROVED_TITLE": "تمت الموافقة على طلب المال", + "MONEY_REQUEST_APPROVED_MESSAGE": "تمت الموافقة على طلبك بمبلغ {amount} {currency}. تمت إضافة المال إلى حسابك.", + "MONEY_REQUEST_DECLINED_TITLE": "تم رفض طلب المال", + "MONEY_REQUEST_DECLINED_MESSAGE": "تم رفض طلبك بمبلغ {amount} {currency}. السبب: {reason}", + "KYC_APPROVED_TITLE": "تمت الموافقة على التحقق من الهوية", + "KYC_APPROVED_MESSAGE": "تمت الموافقة على التحقق من هويتك. يمكنك الآن استخدام جميع ميزات التطبيق.", + "KYC_REJECTED_TITLE": "تم رفض التحقق من الهوية", + "KYC_REJECTED_MESSAGE": "تم رفض التحقق من هويتك. السبب: {reason}. يرجى مراجعة معلوماتك والمحاولة مرة أخرى.", + "CARD_CREATED_TITLE": "تم إنشاء البطاقة", + "CARD_CREATED_MESSAGE": "تم إنشاء بطاقتك التي تنتهي بـ {lastFourDigits} بنجاح. يمكنك البدء في استخدامها بمجرد تفعيلها.", + "CARD_BLOCKED_TITLE": "تم حظر البطاقة", + "CARD_BLOCKED_MESSAGE": "تم حظر بطاقتك التي تنتهي بـ {lastFourDigits}. السبب: {reason}. يرجى الاتصال بالدعم للحصول على المساعدة.", + "PROFILE_UPDATED_TITLE": "تم تحديث الملف الشخصي", + "PROFILE_UPDATED_MESSAGE": "تم تحديث ملفك الشخصي. التغييرات: {fields}", + "PROFILE_EMAIL_UPDATED_TITLE": "تم تحديث البريد الإلكتروني", + "PROFILE_EMAIL_UPDATED_MESSAGE": "تم تحديث بريدك الإلكتروني إلى {email}. يرجى التحقق من عنوان بريدك الإلكتروني الجديد.", + "PROFILE_PASSWORD_UPDATED_TITLE": "تم تحديث كلمة المرور", + "PROFILE_PASSWORD_UPDATED_MESSAGE": "تم تحديث كلمة المرور بنجاح. إذا لم تقم بهذا التغيير، يرجى الاتصال بالدعم فوراً.", + "PROFILE_PICTURE_UPDATED_TITLE": "تم تحديث صورة الملف الشخصي", + "PROFILE_PICTURE_UPDATED_MESSAGE": "تم تحديث صورة ملفك الشخصي بنجاح.", + "PROFILE_NAME_UPDATED_TITLE": "تم تحديث الاسم", + "PROFILE_NAME_UPDATED_MESSAGE": "تم تحديث اسمك بنجاح.", + "MAINTENANCE_ALERT_TITLE": "صيانة مجدولة", + "MAINTENANCE_ALERT_MESSAGE": "{message}", + "TRANSACTION_FAILED_TITLE": "فشلت المعاملة", + "TRANSACTION_FAILED_MESSAGE": "لم يتم إكمال معاملتك. السبب: {reason}. يرجى المحاولة مرة أخرى أو الاتصال بالدعم إذا استمرت المشكلة.", + "SUSPICIOUS_LOGIN_TITLE": "تم اكتشاف تسجيل دخول مشبوه", + "SUSPICIOUS_LOGIN_MESSAGE": "اكتشفنا محاولة تسجيل دخول من {location} ({ipAddress}) باستخدام {device}. إذا لم تكن أنت، يرجى تغيير كلمة المرور فوراً والاتصال بالدعم." } } diff --git a/src/i18n/en/app.json b/src/i18n/en/app.json index 5a2d83c..790baa7 100644 --- a/src/i18n/en/app.json +++ b/src/i18n/en/app.json @@ -119,6 +119,36 @@ "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" + "YOUR_CHILD": "Your child", + "MONEY_REQUEST_CREATED_TITLE": "Money Request", + "MONEY_REQUEST_CREATED_MESSAGE": "{childName} requested {amount} {currency}. Reason: {reason}", + "MONEY_REQUEST_APPROVED_TITLE": "Money Request Approved", + "MONEY_REQUEST_APPROVED_MESSAGE": "Your request for {amount} {currency} has been approved. The money has been added to your account.", + "MONEY_REQUEST_DECLINED_TITLE": "Money Request Declined", + "MONEY_REQUEST_DECLINED_MESSAGE": "Your request for {amount} {currency} has been declined. Reason: {reason}", + "KYC_APPROVED_TITLE": "KYC Verification Approved", + "KYC_APPROVED_MESSAGE": "Your KYC verification has been approved. You can now use all features of the app.", + "KYC_REJECTED_TITLE": "KYC Verification Rejected", + "KYC_REJECTED_MESSAGE": "Your KYC verification has been rejected. Reason: {reason}. Please review your information and try again.", + "CARD_CREATED_TITLE": "Card Created", + "CARD_CREATED_MESSAGE": "Your card ending in {lastFourDigits} has been created successfully. You can start using it once it's activated.", + "CARD_BLOCKED_TITLE": "Card Blocked", + "CARD_BLOCKED_MESSAGE": "Your card ending in {lastFourDigits} has been blocked. Reason: {reason}. Please contact support for assistance.", + "PROFILE_UPDATED_TITLE": "Profile Updated", + "PROFILE_UPDATED_MESSAGE": "Your profile has been updated. Changes: {fields}", + "PROFILE_EMAIL_UPDATED_TITLE": "Email Updated", + "PROFILE_EMAIL_UPDATED_MESSAGE": "Your email has been updated to {email}. Please verify your new email address.", + "PROFILE_PASSWORD_UPDATED_TITLE": "Password Updated", + "PROFILE_PASSWORD_UPDATED_MESSAGE": "Your password has been successfully updated. If you did not make this change, please contact support immediately.", + "PROFILE_PICTURE_UPDATED_TITLE": "Profile Picture Updated", + "PROFILE_PICTURE_UPDATED_MESSAGE": "Your profile picture has been updated successfully.", + "PROFILE_NAME_UPDATED_TITLE": "Name Updated", + "PROFILE_NAME_UPDATED_MESSAGE": "Your name has been updated successfully.", + "MAINTENANCE_ALERT_TITLE": "Scheduled Maintenance", + "MAINTENANCE_ALERT_MESSAGE": "{message}", + "TRANSACTION_FAILED_TITLE": "Transaction Failed", + "TRANSACTION_FAILED_MESSAGE": "Your transaction could not be completed. Reason: {reason}. Please try again or contact support if the issue persists.", + "SUSPICIOUS_LOGIN_TITLE": "Suspicious Login Detected", + "SUSPICIOUS_LOGIN_MESSAGE": "We detected a login attempt from {location} ({ipAddress}) using {device}. If this was not you, please change your password immediately and contact support." } } diff --git a/src/money-request/repositories/money-requests.repository.ts b/src/money-request/repositories/money-requests.repository.ts index 3e3bb6f..3eab265 100644 --- a/src/money-request/repositories/money-requests.repository.ts +++ b/src/money-request/repositories/money-requests.repository.ts @@ -79,6 +79,8 @@ export class MoneyRequestsRepository { 'junior.customer', 'junior.customer.user', 'junior.customer.user.profilePicture', + 'junior.customer.cards', + 'junior.customer.cards.account', 'guardian', 'guardian.customer', 'guardian.customer.user', diff --git a/src/user/controllers/user.controller.ts b/src/user/controllers/user.controller.ts index 0b7005c..1848f6e 100644 --- a/src/user/controllers/user.controller.ts +++ b/src/user/controllers/user.controller.ts @@ -1,15 +1,15 @@ -import { Body, Controller, Get, Headers, HttpCode, HttpStatus, Patch, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, HttpStatus, Patch, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { VerifyOtpRequestDto } from '~/auth/dtos/request'; import { UserResponseDto } from '~/auth/dtos/response'; import { IJwtPayload } from '~/auth/interfaces'; -import { DEVICE_ID_HEADER } from '~/common/constants'; import { AuthenticatedUser } from '~/common/decorators'; import { AccessTokenGuard } from '~/common/guards'; import { ApiDataResponse } from '~/core/decorators'; import { ResponseFactory } from '~/core/utils'; import { UpdateNotificationsSettingsRequestDto, UpdateUserRequestDto } from '../dtos/request'; import { UpdateEmailRequestDto } from '../dtos/request/update-email.request.dto'; +import { NotificationsSettingsResponseDto } from '../dtos/response/notifications-settings.response.dto'; import { UserService } from '../services'; @Controller('profile') @@ -45,13 +45,22 @@ export class UserController { return this.userService.verifyEmail(user.sub, otp); } + @Get('notifications-settings') + @HttpCode(HttpStatus.OK) + @ApiDataResponse(NotificationsSettingsResponseDto) + async getNotificationSettings(@AuthenticatedUser() { sub }: IJwtPayload) { + const user = await this.userService.findUserOrThrow({ id: sub }); + return ResponseFactory.data(new NotificationsSettingsResponseDto({ + isPushEnabled: user.isPushEnabled ?? true, + })); + } + @Patch('notifications-settings') @HttpCode(HttpStatus.NO_CONTENT) async updateNotificationSettings( @AuthenticatedUser() user: IJwtPayload, @Body() data: UpdateNotificationsSettingsRequestDto, - @Headers(DEVICE_ID_HEADER) deviceId: string, ) { - return this.userService.updateNotificationSettings(user.sub, data, deviceId); + return this.userService.updateNotificationSettings(user.sub, data, data.deviceId); } } diff --git a/src/user/dtos/request/update-notifications-settings.request.dto.ts b/src/user/dtos/request/update-notifications-settings.request.dto.ts index 958d625..0c60f7f 100644 --- a/src/user/dtos/request/update-notifications-settings.request.dto.ts +++ b/src/user/dtos/request/update-notifications-settings.request.dto.ts @@ -2,23 +2,19 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsBoolean, IsOptional, IsString, ValidateIf } from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; export class UpdateNotificationsSettingsRequestDto { - @ApiProperty() - @IsBoolean({ message: i18n('validation.IsBoolean', { path: 'general', property: 'customer.isEmailEnabled' }) }) - @IsOptional() - isEmailEnabled!: boolean; - - @ApiProperty() + @ApiPropertyOptional({ example: true, description: 'Enable/disable push notifications (default: true)', default: true }) @IsBoolean({ message: i18n('validation.IsBoolean', { path: 'general', property: 'customer.isPushEnabled' }) }) @IsOptional() - isPushEnabled!: boolean; + isPushEnabled?: boolean; - @ApiProperty() - @IsBoolean({ message: i18n('validation.IsBoolean', { path: 'general', property: 'customer.isSmsEnabled' }) }) - @IsOptional() - isSmsEnabled!: boolean; - - @ApiPropertyOptional() + @ApiPropertyOptional({ example: 'cXYzABC:APA91bH...', description: 'Firebase Cloud Messaging token (required if enabling push)' }) @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) }) - @ValidateIf((o) => o.isPushEnabled) + @ValidateIf((o) => o.isPushEnabled !== false) + @IsOptional() fcmToken?: string; + + @ApiPropertyOptional({ example: 'device-123', description: 'Device identifier (optional, will be found automatically if not provided)' }) + @IsOptional() + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceId' }) }) + deviceId?: string; } diff --git a/src/user/dtos/response/notifications-settings.response.dto.ts b/src/user/dtos/response/notifications-settings.response.dto.ts new file mode 100644 index 0000000..26267ab --- /dev/null +++ b/src/user/dtos/response/notifications-settings.response.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class NotificationsSettingsResponseDto { + @ApiProperty({ example: true, description: 'Push notifications enabled/disabled' }) + isPushEnabled!: boolean; + + constructor(data: { isPushEnabled: boolean }) { + this.isPushEnabled = data.isPushEnabled; + } +} diff --git a/src/user/entities/user.entity.ts b/src/user/entities/user.entity.ts index 97ef32b..72cc9b4 100644 --- a/src/user/entities/user.entity.ts +++ b/src/user/entities/user.entity.ts @@ -61,7 +61,7 @@ export class User extends BaseEntity { @Column({ name: 'is_email_enabled', default: false }) isEmailEnabled!: boolean; - @Column({ name: 'is_push_enabled', default: false }) + @Column({ name: 'is_push_enabled', default: true }) isPushEnabled!: boolean; @Column({ name: 'is_sms_enabled', default: false }) diff --git a/src/user/repositories/device.repository.ts b/src/user/repositories/device.repository.ts index 62b6b59..ebf1961 100644 --- a/src/user/repositories/device.repository.ts +++ b/src/user/repositories/device.repository.ts @@ -26,4 +26,8 @@ export class DeviceRepository { getTokens(userId: string) { return this.deviceRepository.find({ where: { userId, fcmToken: Not(IsNull()) }, select: ['fcmToken'] }); } + + findUserDevices(userId: string) { + return this.deviceRepository.find({ where: { userId } }); + } } diff --git a/src/user/services/device.service.ts b/src/user/services/device.service.ts index 52c4743..8ec8c1b 100644 --- a/src/user/services/device.service.ts +++ b/src/user/services/device.service.ts @@ -31,4 +31,9 @@ export class DeviceService { return devices.map((device) => device.fcmToken!); } + + findUserDevices(userId: string) { + this.logger.log(`Finding all devices for user ${userId}`); + return this.deviceRepository.findUserDevices(userId); + } } diff --git a/src/user/services/user.service.ts b/src/user/services/user.service.ts index cc7aa12..dbb2d30 100644 --- a/src/user/services/user.service.ts +++ b/src/user/services/user.service.ts @@ -1,9 +1,12 @@ import { BadRequestException, forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { ConfigService } from '@nestjs/config'; import * as bcrypt from 'bcrypt'; import { FindOptionsWhere } from 'typeorm'; import { Transactional } from 'typeorm-transactional'; import { CountryIso } from '~/common/enums'; +import { NOTIFICATION_EVENTS } from '~/common/modules/notification/constants/event-names.constant'; +import { IProfileUpdatedEvent } from '~/common/modules/notification/interfaces/notification-events.interface'; import { NotificationsService } from '~/common/modules/notification/services'; import { OtpScope, OtpType } from '~/common/modules/otp/enums'; import { OtpService } from '~/common/modules/otp/services'; @@ -28,6 +31,7 @@ export class UserService { private readonly documentService: DocumentService, private readonly otpService: OtpService, private readonly ociService: OciService, + private readonly eventEmitter: EventEmitter2, ) {} async findUser(where: FindOptionsWhere | FindOptionsWhere[], includeSignedUrl = false) { @@ -131,22 +135,55 @@ export class UserService { async updateNotificationSettings(userId: string, data: UpdateNotificationsSettingsRequestDto, deviceId?: string) { this.logger.log(`Updating notification settings for user ${userId} with data ${JSON.stringify(data)}`); - if (data.isPushEnabled && !data.fcmToken) { + + const isPushEnabled = data.isPushEnabled ?? true; + + if (isPushEnabled && !data.fcmToken) { throw new BadRequestException('USER.FCM_TOKEN_REQUIRED'); } - if (data.isPushEnabled && !deviceId) { - throw new BadRequestException('DEVICE_ID_REQUIRED'); - } + if (isPushEnabled && data.fcmToken) { + let targetDeviceId = deviceId; - if (data.isPushEnabled && deviceId && data.fcmToken) { - await this.deviceService.updateDevice(deviceId, { fcmToken: data.fcmToken, userId }); + if (!targetDeviceId) { + const userDevices = await this.deviceService.findUserDevices(userId); + if (userDevices.length > 0) { + targetDeviceId = userDevices[0].deviceId; + this.logger.log(`No deviceId provided, using first device: ${targetDeviceId}`); + } else { + targetDeviceId = `device-${userId}-${Date.now()}`; + this.logger.log(`No device found, creating new device: ${targetDeviceId}`); + await this.deviceService.createDevice({ + deviceId: targetDeviceId, + userId, + fcmToken: data.fcmToken, + lastAccessOn: new Date(), + }); + } + } + + if (targetDeviceId) { + const existingDevice = await this.deviceService.findUserDeviceById(targetDeviceId, userId); + if (existingDevice) { + await this.deviceService.updateDevice(targetDeviceId, { fcmToken: data.fcmToken, userId }); + } else { + const anyDevice = await this.deviceService.findByDeviceId(targetDeviceId); + if (anyDevice) { + await this.deviceService.updateDevice(targetDeviceId, { fcmToken: data.fcmToken, userId }); + } else { + await this.deviceService.createDevice({ + deviceId: targetDeviceId, + userId, + fcmToken: data.fcmToken, + lastAccessOn: new Date(), + }); + } + } + } } await this.userRepository.update(userId, { - isPushEnabled: data.isPushEnabled, - isEmailEnabled: data.isEmailEnabled, - isSmsEnabled: data.isSmsEnabled, + isPushEnabled: isPushEnabled, }); } @@ -216,6 +253,19 @@ export class UserService { } await this.customerService.updateCustomer(userId, customerData); } + + const updatedUser = await this.findUserOrThrow({ id: userId }); + const updatedFields = Object.keys(data).filter(key => (data as any)[key] !== undefined); + + if (updatedFields.length > 0) { + const event: IProfileUpdatedEvent = { + user: updatedUser, + updatedFields, + timestamp: new Date(), + }; + this.eventEmitter.emit(NOTIFICATION_EVENTS.PROFILE_UPDATED, event); + this.logger.log(`Emitted PROFILE_UPDATED event for user ${userId}, updated fields: ${updatedFields.join(', ')}`); + } } async updateUserEmail(userId: string, email: string) { @@ -246,6 +296,15 @@ export class UserService { throw new BadRequestException('USER.NOT_FOUND'); } + const updatedUser = await this.findUserOrThrow({ id: userId }); + const event: IProfileUpdatedEvent = { + user: updatedUser, + updatedFields: ['email'], + timestamp: new Date(), + }; + this.eventEmitter.emit(NOTIFICATION_EVENTS.PROFILE_UPDATED, event); + this.logger.log(`Emitted PROFILE_UPDATED event for user ${userId}, updated field: email`); + return this.otpService.generateAndSendOtp({ userId, recipient: email,