From 9c93a35093fa190e41a8b85d9a7b4ee4e0cae125 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Tue, 6 Jan 2026 12:29:01 +0300 Subject: [PATCH] feat: implement notification system with FCM token registration - Add FCM token registration during login/signup - Implement transaction notification listeners - Add notification data column to database - Update Firebase service with data payload support - Add transaction notification scopes - Update card repository to load relations for notifications --- .../dtos/request/junior-login.request.dto.ts | 16 +++- src/auth/dtos/request/login.request.dto.ts | 14 ++++ .../dtos/request/verify-user.request.dto.ts | 14 ++++ src/auth/services/auth.service.ts | 55 +++++++++++++ src/card/repositories/card.repository.ts | 33 +++++++- src/card/services/transaction.service.ts | 45 ++++++++++ .../entities/notification.entity.ts | 3 + .../enums/notification-scope.enum.ts | 11 ++- .../modules/notification/interfaces/index.ts | 1 + .../modules/notification/listeners/index.ts | 1 + .../notification-created.listener.ts | 23 +++++- .../notification/notification.module.ts | 8 +- .../notification/services/firebase.service.ts | 82 +++++++++++++++---- .../modules/notification/services/index.ts | 1 + src/db/migrations/index.ts | 3 +- 15 files changed, 281 insertions(+), 29 deletions(-) diff --git a/src/auth/dtos/request/junior-login.request.dto.ts b/src/auth/dtos/request/junior-login.request.dto.ts index b53a842..42e1e49 100644 --- a/src/auth/dtos/request/junior-login.request.dto.ts +++ b/src/auth/dtos/request/junior-login.request.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail, IsString } from 'class-validator'; +import { IsEmail, IsOptional, IsString } from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; export class JuniorLoginRequestDto { @ApiProperty({ example: 'test@junior.com' }) @@ -9,4 +9,18 @@ export class JuniorLoginRequestDto { @ApiProperty({ example: 'Abcd1234@' }) @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) }) password!: string; + + @ApiProperty({ example: 'device-123', description: 'Unique device identifier', required: false }) + @IsOptional() + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceId' }) }) + deviceId?: string; + + @ApiProperty({ + example: 'cXYzABC:APA91bHunvwY7rKpn8N7y6vDxS0qmQ5RZx2C8K...', + description: 'Firebase Cloud Messaging token for push notifications', + required: false, + }) + @IsOptional() + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) }) + fcmToken?: string; } diff --git a/src/auth/dtos/request/login.request.dto.ts b/src/auth/dtos/request/login.request.dto.ts index ed48ae3..80dc810 100644 --- a/src/auth/dtos/request/login.request.dto.ts +++ b/src/auth/dtos/request/login.request.dto.ts @@ -21,4 +21,18 @@ export class LoginRequestDto { @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) }) @ValidateIf((o) => o.grantType === GrantType.PASSWORD) password!: string; + + @ApiProperty({ example: 'device-123', description: 'Unique device identifier', required: false }) + @IsOptional() + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceId' }) }) + deviceId?: string; + + @ApiProperty({ + example: 'cXYzABC:APA91bHunvwY7rKpn8N7y6vDxS0qmQ5RZx2C8K...', + description: 'Firebase Cloud Messaging token for push notifications', + required: false, + }) + @IsOptional() + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) }) + fcmToken?: string; } diff --git a/src/auth/dtos/request/verify-user.request.dto.ts b/src/auth/dtos/request/verify-user.request.dto.ts index 94944a2..241d984 100644 --- a/src/auth/dtos/request/verify-user.request.dto.ts +++ b/src/auth/dtos/request/verify-user.request.dto.ts @@ -80,4 +80,18 @@ export class VerifyUserRequestDto { message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), }) otp!: string; + + @ApiProperty({ example: 'device-123', description: 'Unique device identifier', required: false }) + @IsOptional() + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceId' }) }) + deviceId?: string; + + @ApiProperty({ + example: 'cXYzABC:APA91bHunvwY7rKpn8N7y6vDxS0qmQ5RZx2C8K...', + description: 'Firebase Cloud Messaging token for push notifications', + required: false, + }) + @IsOptional() + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) }) + fcmToken?: string; } diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index 5330bed..59a22d9 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -94,6 +94,12 @@ export class AuthService { const tokens = await this.generateAuthToken(user); this.logger.log(`User with phone number ${user.fullPhoneNumber} verified successfully`); + + // Register/update device with FCM token if provided + if (verifyUserDto.fcmToken && verifyUserDto.deviceId) { + await this.registerDeviceToken(user.id, verifyUserDto.deviceId, verifyUserDto.fcmToken); + } + return [tokens, user]; } @@ -279,6 +285,12 @@ export class AuthService { const tokens = await this.generateAuthToken(user); this.logger.log(`Password validated successfully for user`); + + // Register/update device with FCM token if provided + if (loginDto.fcmToken && loginDto.deviceId) { + await this.registerDeviceToken(user.id, loginDto.deviceId, loginDto.fcmToken); + } + return [tokens, user]; } @@ -299,9 +311,52 @@ export class AuthService { const tokens = await this.generateAuthToken(user); this.logger.log(`Password validated successfully for user`); + + // Register/update device with FCM token if provided + if (juniorLoginDto.fcmToken && juniorLoginDto.deviceId) { + await this.registerDeviceToken(user.id, juniorLoginDto.deviceId, juniorLoginDto.fcmToken); + } + return [tokens, user]; } + /** + * Register or update device with FCM token + * This method handles both new device registration and existing device updates + */ + private async registerDeviceToken(userId: string, deviceId: string, fcmToken: string): Promise { + 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); + + if (existingDevice) { + // Update existing device with new 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, + 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); + const errorStack = error instanceof Error ? error.stack : undefined; + this.logger.error(`Failed to register device token for user ${userId}: ${errorMessage}`, errorStack); + } + } + private async generateAuthToken(user: User) { this.logger.log(`Generating auth token for user with id ${user.id}`); const [accessToken, refreshToken] = await Promise.all([ diff --git a/src/card/repositories/card.repository.ts b/src/card/repositories/card.repository.ts index 18a161f..4d33c9f 100644 --- a/src/card/repositories/card.repository.ts +++ b/src/card/repositories/card.repository.ts @@ -42,7 +42,18 @@ export class CardRepository { } getCardById(id: string): Promise { - return this.cardRepository.findOne({ where: { id }, relations: ['account'] }); + return this.cardRepository.findOne({ + where: { id }, + relations: [ + 'account', + 'customer', + 'customer.user', + 'customer.junior', + 'customer.junior.guardian', + 'customer.junior.guardian.customer', + 'customer.junior.guardian.customer.user', + ], + }); } findCardByChildId(guardianId: string, childId: string): Promise { @@ -59,14 +70,30 @@ export class CardRepository { getCardByVpan(vpan: string): Promise { return this.cardRepository.findOne({ where: { vpan }, - relations: ['account'], + relations: [ + 'account', + 'customer', + 'customer.user', + 'customer.junior', + 'customer.junior.guardian', + 'customer.junior.guardian.customer', + 'customer.junior.guardian.customer.user', + ], }); } getCardByCustomerId(customerId: string): Promise { return this.cardRepository.findOne({ where: { customerId }, - relations: ['account'], + relations: [ + 'account', + 'customer', + 'customer.user', + 'customer.junior', + 'customer.junior.guardian', + 'customer.junior.guardian.customer', + 'customer.junior.guardian.customer.user', + ], }); } diff --git a/src/card/services/transaction.service.ts b/src/card/services/transaction.service.ts index 613d10c..2d95e2e 100644 --- a/src/card/services/transaction.service.ts +++ b/src/card/services/transaction.service.ts @@ -1,4 +1,5 @@ import { forwardRef, Inject, Injectable, UnprocessableEntityException } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import Decimal from 'decimal.js'; import moment from 'moment'; import { Transactional } from 'typeorm-transactional'; @@ -6,6 +7,8 @@ import { AccountTransactionWebhookRequest, CardTransactionWebhookRequest, } from '~/common/modules/neoleap/dtos/requests'; +import { NOTIFICATION_EVENTS } from '~/common/modules/notification/constants/event-names.constant'; +import { ITransactionCreatedEvent } from '~/common/modules/notification/interfaces/notification-events.interface'; import { Transaction } from '../entities/transaction.entity'; import { CustomerType, TransactionType } from '../enums'; import { TransactionRepository } from '../repositories/transaction.repository'; @@ -27,6 +30,7 @@ export class TransactionService { private readonly transactionRepository: TransactionRepository, private readonly accountService: AccountService, @Inject(forwardRef(() => CardService)) private readonly cardService: CardService, + private readonly eventEmitter: EventEmitter2, ) {} @Transactional() @@ -58,6 +62,16 @@ export class TransactionService { await this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber()); } + // Emit event for notification system + const event: ITransactionCreatedEvent = { + transaction, + card, // Pass card with all relations loaded + isTopUp: false, // Card transactions are spending + isChildSpending: card.customerType === CustomerType.CHILD, + timestamp: new Date(), + }; + this.eventEmitter.emit(NOTIFICATION_EVENTS.TRANSACTION_CREATED, event); + return transaction; } @@ -74,12 +88,43 @@ export class TransactionService { const transaction = await this.transactionRepository.createAccountTransaction(account, body); await this.accountService.creditAccountBalance(account.accountReference, body.amount); + // Get card for notification system by account ID + // Account transactions are top-ups, so we get the first card associated with the account + const accountWithCards = await this.accountService.getAccountByAccountNumber(body.accountId); + const card = accountWithCards.cards?.[0] + ? await this.cardService.getCardById(accountWithCards.cards[0].id) + : null; + + // Only emit event if card exists (we need card for user info) + if (card) { + // Emit event for notification system + const event: ITransactionCreatedEvent = { + transaction, + card, // Pass card with all relations loaded + isTopUp: true, // Account transactions are top-ups + isChildSpending: false, // Top-ups are typically not from children + timestamp: new Date(), + }; + this.eventEmitter.emit(NOTIFICATION_EVENTS.TRANSACTION_CREATED, event); + } + return transaction; } async createInternalChildTransaction(cardId: string, amount: number) { const card = await this.cardService.getCardById(cardId); const transaction = await this.transactionRepository.createInternalChildTransaction(card, amount); + + // Emit event for notification system + const event: ITransactionCreatedEvent = { + transaction, + card, // Pass card with all relations loaded + isTopUp: true, // Internal child transaction is a top-up to child's card + isChildSpending: true, // Child's card is being topped up + timestamp: new Date(), + }; + this.eventEmitter.emit(NOTIFICATION_EVENTS.TRANSACTION_CREATED, event); + return transaction; } diff --git a/src/common/modules/notification/entities/notification.entity.ts b/src/common/modules/notification/entities/notification.entity.ts index 867b8ec..43f4364 100644 --- a/src/common/modules/notification/entities/notification.entity.ts +++ b/src/common/modules/notification/entities/notification.entity.ts @@ -36,6 +36,9 @@ export class Notification { @Column('uuid', { name: 'user_id', nullable: true }) userId!: string; + @Column('jsonb', { name: 'data', nullable: true }) + data?: Record; + @ManyToOne(() => User, (user) => user.notifications, { onDelete: 'CASCADE', nullable: true }) @JoinColumn({ name: 'user_id' }) user!: User; diff --git a/src/common/modules/notification/enums/notification-scope.enum.ts b/src/common/modules/notification/enums/notification-scope.enum.ts index 1f6cd4b..de0a485 100644 --- a/src/common/modules/notification/enums/notification-scope.enum.ts +++ b/src/common/modules/notification/enums/notification-scope.enum.ts @@ -1,7 +1,16 @@ export enum NotificationScope { + // Existing scopes USER_REGISTERED = 'USER_REGISTERED', TASK_COMPLETED = 'TASK_COMPLETED', GIFT_RECEIVED = 'GIFT_RECEIVED', OTP = 'OTP', USER_INVITED = 'USER_INVITED', -} + + // Transaction notifications - Top-up + CHILD_TOP_UP = 'CHILD_TOP_UP', + PARENT_TOP_UP_CONFIRMATION = 'PARENT_TOP_UP_CONFIRMATION', + + // Transaction notifications - Spending + CHILD_SPENDING = 'CHILD_SPENDING', + PARENT_SPENDING_ALERT = 'PARENT_SPENDING_ALERT', +} \ No newline at end of file diff --git a/src/common/modules/notification/interfaces/index.ts b/src/common/modules/notification/interfaces/index.ts index 9042da3..cf5bf15 100644 --- a/src/common/modules/notification/interfaces/index.ts +++ b/src/common/modules/notification/interfaces/index.ts @@ -1 +1,2 @@ export * from './notification-page-meta.interface'; +export * from './notification-events.interface'; diff --git a/src/common/modules/notification/listeners/index.ts b/src/common/modules/notification/listeners/index.ts index ab630df..a582647 100644 --- a/src/common/modules/notification/listeners/index.ts +++ b/src/common/modules/notification/listeners/index.ts @@ -1 +1,2 @@ export * from './notification-created.listener'; +export * from './transaction-notification.listener'; diff --git a/src/common/modules/notification/listeners/notification-created.listener.ts b/src/common/modules/notification/listeners/notification-created.listener.ts index a009389..1040ed3 100644 --- a/src/common/modules/notification/listeners/notification-created.listener.ts +++ b/src/common/modules/notification/listeners/notification-created.listener.ts @@ -31,7 +31,7 @@ export class NotificationCreatedListener { return this.sendSMS(event.recipient!, event.message); case NotificationChannel.PUSH: - return this.sendPushNotification(event.userId, event.title, event.message); + return this.sendPushNotification(event.userId, event.title, event.message, event.data); case NotificationChannel.EMAIL: return this.sendEmail({ @@ -54,7 +54,12 @@ export class NotificationCreatedListener { } } - private async sendPushNotification(userId: string, title: string, body: string) { + private async sendPushNotification( + userId: string, + title: string, + body: string, + data?: Record, + ) { this.logger.log(`Sending push notification to user ${userId}`); const tokens = await this.deviceService.getTokens(userId); @@ -62,7 +67,19 @@ export class NotificationCreatedListener { this.logger.log(`No device tokens found for user ${userId}, but notification was created in the DB.`); return; } - return this.firebaseService.sendNotification(tokens, title, body); + + // 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/notification.module.ts b/src/common/modules/notification/notification.module.ts index 798a957..7a3c521 100644 --- a/src/common/modules/notification/notification.module.ts +++ b/src/common/modules/notification/notification.module.ts @@ -8,9 +8,9 @@ import { buildMailerOptions, buildTwilioOptions } from '~/core/module-options'; import { UserModule } from '~/user/user.module'; import { NotificationsController } from './controllers'; import { Notification } from './entities'; -import { NotificationCreatedListener } from './listeners'; +import { NotificationCreatedListener, TransactionNotificationListener } from './listeners'; import { NotificationsRepository } from './repositories'; -import { FirebaseService, NotificationsService, TwilioService } from './services'; +import { FirebaseService, NotificationFactory, NotificationsService, TwilioService } from './services'; @Module({ imports: [ @@ -28,12 +28,14 @@ import { FirebaseService, NotificationsService, TwilioService } from './services ], providers: [ NotificationsService, + NotificationFactory, FirebaseService, NotificationsRepository, TwilioService, NotificationCreatedListener, + TransactionNotificationListener, ], - exports: [NotificationsService, NotificationCreatedListener], + exports: [NotificationsService, NotificationFactory, NotificationCreatedListener], controllers: [NotificationsController], }) export class NotificationModule {} diff --git a/src/common/modules/notification/services/firebase.service.ts b/src/common/modules/notification/services/firebase.service.ts index 5555d81..4cb7ce9 100644 --- a/src/common/modules/notification/services/firebase.service.ts +++ b/src/common/modules/notification/services/firebase.service.ts @@ -1,29 +1,77 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as admin from 'firebase-admin'; + @Injectable() export class FirebaseService { private readonly logger = new Logger(FirebaseService.name); + constructor(private readonly configService: ConfigService) { - admin.initializeApp({ - credential: admin.credential.cert({ - projectId: this.configService.get('FIREBASE_PROJECT_ID'), - clientEmail: this.configService.get('FIREBASE_CLIENT_EMAIL'), - privateKey: this.configService.get('FIREBASE_PRIVATE_KEY').replace(/\\n/g, '\n'), - }), - }); + try { + this.logger.log('🔥 Initializing Firebase Admin SDK...'); + + const projectId = this.configService.get('FIREBASE_PROJECT_ID'); + const clientEmail = this.configService.get('FIREBASE_CLIENT_EMAIL'); + const privateKey = this.configService.get('FIREBASE_PRIVATE_KEY'); + + // Log configuration (without exposing sensitive data) + this.logger.log(`📋 Project ID: ${projectId}`); + this.logger.log(`📋 Client Email: ${clientEmail}`); + this.logger.log(`📋 Private Key: ${privateKey ? 'SET ✅' : 'MISSING ❌'}`); + + admin.initializeApp({ + credential: admin.credential.cert({ + projectId, + clientEmail, + privateKey: privateKey.replace(/\\n/g, '\n'), + }), + }); + + this.logger.log('✅ Firebase Admin SDK initialized successfully!'); + this.logger.log(`📱 Connected to project: ${projectId}`); + } catch (error: any) { + this.logger.error('❌ Failed to initialize Firebase Admin SDK'); + this.logger.error(`Error: ${error.message}`); + throw error; + } } - sendNotification(tokens: string | string[], title: string, body: string) { - this.logger.log(`Sending push notification to ${tokens}`); - const message = { - notification: { - title, - body, - }, - tokens: Array.isArray(tokens) ? tokens : [tokens], - }; + async sendNotification(tokens: string | string[], title: string, body: string, data?: Record) { + this.logger.log( + `Sending push notification to ${Array.isArray(tokens) ? tokens.length : 1} device(s)`, + ); - admin.messaging().sendEachForMulticast(message); + try { + const message = { + notification: { + title, + body, + }, + data: data || {}, + tokens: Array.isArray(tokens) ? tokens : [tokens], + }; + + const response = await admin.messaging().sendEachForMulticast(message); + + this.logger.log( + `✅ Push sent! Success: ${response.successCount}, Failed: ${response.failureCount}`, + ); + + // Log failed tokens for debugging + if (response.failureCount > 0) { + response.responses.forEach((resp, idx) => { + if (!resp.success) { + this.logger.warn( + `Failed to send to token ${idx}: ${resp.error?.code} - ${resp.error?.message}`, + ); + } + }); + } + + return response; + } catch (error: any) { + this.logger.error(`❌ Failed to send push notification: ${error.message}`); + throw error; + } } } diff --git a/src/common/modules/notification/services/index.ts b/src/common/modules/notification/services/index.ts index 772a718..ff2e3a1 100644 --- a/src/common/modules/notification/services/index.ts +++ b/src/common/modules/notification/services/index.ts @@ -1,3 +1,4 @@ export * from './firebase.service'; +export * from './notification-factory.service'; export * from './notifications.service'; export * from './twilio.service'; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 1d5acc9..b4dd7ce 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -5,4 +5,5 @@ export * from './1757349525708-create-money-requests-table'; export * from './1757433339849-add-reservation-amount-to-account-entity'; export * from './1757915357218-add-deleted-at-column-to-junior'; export * from './1760869651296-AddMerchantInfoToTransactions'; -export * from './1761032305682-AddUniqueConstraintToUserEmail'; \ No newline at end of file +export * from './1761032305682-AddUniqueConstraintToUserEmail'; +export * from './1767172707881-AddDataColumnToNotifications';