Merge pull request #83 from Zod-Alkhair/feature/notification-system-fcm-registration

feat: add timezone support to user and device entities
This commit is contained in:
Majdalkilany0
2026-01-14 16:12:59 +03:00
committed by GitHub
14 changed files with 171 additions and 50 deletions

View File

@ -23,4 +23,13 @@ export class JuniorLoginRequestDto {
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) })
fcmToken?: string;
@ApiProperty({
example: 'Asia/Riyadh',
description: 'Device timezone (auto-detected from device OS)',
required: false,
})
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.timezone' }) })
timezone?: string;
}

View File

@ -35,4 +35,13 @@ export class LoginRequestDto {
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) })
fcmToken?: string;
@ApiProperty({
example: 'Asia/Riyadh',
description: 'Device timezone (auto-detected from device OS)',
required: false,
})
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.timezone' }) })
timezone?: string;
}

View File

@ -115,4 +115,13 @@ export class VerifyUserRequestDto {
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) })
fcmToken?: string;
@ApiProperty({
example: 'Asia/Riyadh',
description: 'Device timezone (auto-detected from device OS)',
required: false,
})
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.timezone' }) })
timezone?: string;
}

View File

@ -87,9 +87,9 @@ 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
// Register/update device with FCM token and timezone if provided
if (verifyUserDto.fcmToken && verifyUserDto.deviceId) {
await this.registerDeviceToken(user.id, verifyUserDto.deviceId, verifyUserDto.fcmToken);
await this.registerDeviceToken(user.id, verifyUserDto.deviceId, verifyUserDto.fcmToken, verifyUserDto.timezone);
}
return [tokens, user];
@ -278,9 +278,9 @@ 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
// Register/update device with FCM token and timezone if provided
if (loginDto.fcmToken && loginDto.deviceId) {
await this.registerDeviceToken(user.id, loginDto.deviceId, loginDto.fcmToken);
await this.registerDeviceToken(user.id, loginDto.deviceId, loginDto.fcmToken, loginDto.timezone);
}
return [tokens, user];
@ -304,22 +304,22 @@ 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
// Register/update device with FCM token and timezone if provided
if (juniorLoginDto.fcmToken && juniorLoginDto.deviceId) {
await this.registerDeviceToken(user.id, juniorLoginDto.deviceId, juniorLoginDto.fcmToken);
await this.registerDeviceToken(user.id, juniorLoginDto.deviceId, juniorLoginDto.fcmToken, juniorLoginDto.timezone);
}
return [tokens, user];
}
/**
* Register or update device with FCM token
* Register or update device with FCM token and timezone
* This method handles:
* 1. Device already exists for this user → Update FCM token
* 1. Device already exists for this user → Update FCM token and timezone
* 2. Device exists for different user → Transfer device to new user
* 3. Device doesn't exist → Create new device
*/
private async registerDeviceToken(userId: string, deviceId: string, fcmToken: string): Promise<void> {
private async registerDeviceToken(userId: string, deviceId: string, fcmToken: string, timezone?: string): Promise<void> {
try {
this.logger.log(`Registering/updating device ${deviceId} with FCM token for user ${userId}`);
@ -327,13 +327,14 @@ export class AuthService {
const existingDeviceForUser = await this.deviceService.findUserDeviceById(deviceId, userId);
if (existingDeviceForUser) {
// Device exists for this user → Update FCM token and last access time
// Device exists for this user → Update FCM token, timezone, and last access time
await this.deviceService.updateDevice(deviceId, {
fcmToken,
userId,
timezone, // Update timezone if provided
lastAccessOn: new Date(),
});
this.logger.log(`Device ${deviceId} updated with new FCM token for user ${userId}`);
this.logger.log(`Device ${deviceId} updated with new FCM token and timezone for user ${userId}`);
return;
}
@ -348,6 +349,7 @@ export class AuthService {
await this.deviceService.updateDevice(deviceId, {
userId,
fcmToken,
timezone, // Update timezone if provided
lastAccessOn: new Date(),
});
this.logger.log(`Device ${deviceId} transferred from user ${existingDevice.userId} to user ${userId}`);
@ -359,6 +361,7 @@ export class AuthService {
deviceId,
userId,
fcmToken,
timezone, // Store timezone if provided
lastAccessOn: new Date(),
});
this.logger.log(`New device ${deviceId} registered with FCM token for user ${userId}`);

View File

@ -26,12 +26,13 @@ export class NotificationsResponseDto {
// 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
// Note: Timestamps are stored in UTC in the database, and should be converted to local timezone on the client side
// Note: Timestamps are stored in UTC. The client should convert to the user's local timezone.
if (notification.data?.timestamp) {
// Parse the ISO string timestamp (which is in UTC)
// The client should convert this to the user's local timezone
// The client should convert this to the user's local timezone based on their device settings
this.createdAt = new Date(notification.data.timestamp);
} else {
// Use notification creation time (also in UTC)
this.createdAt = notification.createdAt;
}
}

View File

@ -109,11 +109,17 @@ export class TransactionNotificationListener {
const cardWithUpdatedBalance = await this.cardService.getCardById(card.id);
const balance = cardWithUpdatedBalance.account?.balance || card.account?.balance || 0;
const accountCurrency = cardWithUpdatedBalance.account?.currency || card.account?.currency;
const currency = getCurrency(
cardWithUpdatedBalance.account?.currency || card.account?.currency,
accountCurrency,
transaction.transactionCurrency,
'SAR'
);
this.logger.debug(
`[Child Notification] Account currency: ${accountCurrency}, Transaction currency: ${transaction.transactionCurrency}, Final currency: ${currency}, Balance: ${balance}, Amount: ${amount}`
);
const formattedAmount = formatCurrencyAmount(amount, currency);
const formattedBalance = formatCurrencyAmount(balance, currency);
@ -166,14 +172,14 @@ export class TransactionNotificationListener {
data: {
transactionId: transaction.id,
amount: formattedAmount,
currency: currency,
currency: currency, // ISO currency code (SAR, USD, etc.)
merchant: merchant,
merchantCategory: transaction.merchantCategoryCode || 'OTHER',
balance: formattedBalance,
timestamp: transaction.transactionDate.toISOString(),
type: isTopUp ? 'TOP_UP' : 'SPENDING',
action: 'OPEN_TRANSACTION',
},
timestamp: transaction.transactionDate.toISOString(),
type: isTopUp ? 'TOP_UP' : 'SPENDING',
action: 'OPEN_TRANSACTION',
},
});
this.logger.log(`✅ Notified user ${user.id} for transaction ${transaction.id}`);
@ -208,30 +214,43 @@ export class TransactionNotificationListener {
const amount = transaction.transactionAmount;
const merchant = transaction.merchantName || 'a merchant';
// Get parent's account balance (not child's balance)
// Get parent's account balance (not child's balance) - reload to get fresh balance
let parentAccountBalance = 0;
let parentAccountCurrency: string | undefined;
try {
const parentCustomer = customer?.junior?.guardian?.customer;
if (parentCustomer?.cards?.[0]?.account) {
parentAccountBalance = parentCustomer.cards[0].account.balance;
parentAccountCurrency = parentCustomer.cards[0].account.currency;
} else if (card.parentId) {
if (card.parentId) {
// Always reload parent account to get fresh balance after transaction
const parentAccount = await this.accountService.getAccountByCustomerId(card.parentId);
parentAccountBalance = parentAccount.balance;
parentAccountCurrency = parentAccount.currency;
this.logger.debug(`Fetched parent account balance: ${parentAccountBalance}, currency: ${parentAccountCurrency}`);
} else {
const parentCustomer = customer?.junior?.guardian?.customer;
if (parentCustomer?.cards?.[0]?.account) {
// Reload to get fresh balance
const parentAccount = await this.accountService.getAccountByCustomerId(parentCustomer.id);
parentAccountBalance = parentAccount.balance;
parentAccountCurrency = parentAccount.currency;
this.logger.debug(`Fetched parent account balance via customer: ${parentAccountBalance}, currency: ${parentAccountCurrency}`);
}
}
} catch (error) {
this.logger.warn(`Could not fetch parent account for parent notification, using child account balance as fallback`);
} catch (error: any) {
this.logger.warn(`Could not fetch parent account for parent notification: ${error?.message}, using child account balance as fallback`);
parentAccountBalance = card.account?.balance || 0;
parentAccountCurrency = card.account?.currency;
}
const accountCurrency = parentAccountCurrency || card.account?.currency;
const currency = getCurrency(
parentAccountCurrency || card.account?.currency,
accountCurrency,
transaction.transactionCurrency,
'SAR'
);
this.logger.debug(
`[Parent Spending Notification] Parent account currency: ${parentAccountCurrency}, Account currency: ${accountCurrency}, Transaction currency: ${transaction.transactionCurrency}, Final currency: ${currency}, Parent balance: ${parentAccountBalance}, Amount: ${amount}`
);
const formattedAmount = formatCurrencyAmount(amount, currency);
const formattedBalance = formatCurrencyAmount(parentAccountBalance, currency);
@ -271,8 +290,8 @@ export class TransactionNotificationListener {
transactionId: transaction.id,
childId: childUser.id,
childName: childName,
amount: amount.toString(),
currency: currency,
amount: formattedAmount, // Use formatted amount instead of raw amount
currency: currency, // ISO currency code (SAR, USD, etc.)
merchant: merchant,
merchantCategory: transaction.merchantCategoryCode || 'OTHER',
balance: formattedBalance,
@ -313,30 +332,41 @@ export class TransactionNotificationListener {
const childName = childUser?.firstName || defaultChildName;
const amount = transaction.transactionAmount;
const parentCustomer = customer?.junior?.guardian?.customer;
let parentAccount = parentCustomer?.cards?.[0]?.account;
// Reload parent account to get updated balance after transfer
if (!parentAccount && card.parentId) {
// Always reload parent account to get updated balance after transfer
let parentAccount: any = null;
if (card.parentId) {
try {
parentAccount = await this.accountService.getAccountByCustomerId(card.parentId);
} catch (error) {
this.logger.warn(`Could not fetch parent account for customer ${card.parentId}, using child account balance`);
this.logger.debug(`Fetched parent account for top-up notification - balance: ${parentAccount.balance}, currency: ${parentAccount.currency}`);
} catch (error: any) {
this.logger.warn(`Could not fetch parent account for customer ${card.parentId}: ${error?.message}, using child account balance`);
}
} else if (parentAccount && card.parentId) {
// Reload to get fresh balance
try {
parentAccount = await this.accountService.getAccountByCustomerId(card.parentId);
} catch (error) {
this.logger.warn(`Could not reload parent account, using cached balance`);
}
// If parent account not found, try via customer relation
if (!parentAccount) {
const parentCustomer = customer?.junior?.guardian?.customer;
if (parentCustomer?.id) {
try {
parentAccount = await this.accountService.getAccountByCustomerId(parentCustomer.id);
this.logger.debug(`Fetched parent account via customer relation - balance: ${parentAccount.balance}, currency: ${parentAccount.currency}`);
} catch (error: any) {
this.logger.warn(`Could not fetch parent account via customer: ${error?.message}`);
}
}
}
const balance = parentAccount?.balance || card.account?.balance || 0;
const accountCurrency = parentAccount?.currency || card.account?.currency;
const currency = getCurrency(
parentAccount?.currency || card.account?.currency,
accountCurrency,
transaction.transactionCurrency,
);
this.logger.debug(
`[Parent Top-Up Notification] Parent account currency: ${parentAccount?.currency}, Account currency: ${accountCurrency}, Transaction currency: ${transaction.transactionCurrency}, Final currency: ${currency}, Parent balance: ${balance}, Amount: ${amount}`
);
const formattedAmount = formatCurrencyAmount(amount, currency);
const formattedBalance = formatCurrencyAmount(balance, currency);
@ -376,7 +406,7 @@ export class TransactionNotificationListener {
childId: childUser.id,
childName: childName,
amount: formattedAmount,
currency: currency,
currency: currency, // ISO currency code (SAR, USD, etc.)
balance: formattedBalance,
timestamp: transaction.transactionDate.toISOString(),
type: 'TOP_UP',

View File

@ -14,7 +14,15 @@ export class RedisModule {
{
provide: 'REDIS_PUBLISHER',
useFactory: async (configService: ConfigService) => {
const publisher = createClient({ url: configService.get<string>('REDIS_URL') });
// Skip Redis connection during migration generation
if (process.env.MIGRATIONS_RUN === 'false') {
return null;
}
const redisUrl = configService.get<string>('REDIS_URL');
if (!redisUrl) {
return null;
}
const publisher = createClient({ url: redisUrl });
await publisher.connect();
return publisher;
},
@ -24,7 +32,15 @@ export class RedisModule {
{
provide: 'REDIS_SUBSCRIBER',
useFactory: async (configService: ConfigService) => {
const subscriber = createClient({ url: configService.get<string>('REDIS_URL') });
// Skip Redis connection during migration generation
if (process.env.MIGRATIONS_RUN === 'false') {
return null;
}
const redisUrl = configService.get<string>('REDIS_URL');
if (!redisUrl) {
return null;
}
const subscriber = createClient({ url: redisUrl });
await subscriber.connect();
return subscriber;
},

View File

@ -15,6 +15,10 @@ export class RedisPubSubService implements OnModuleInit {
) {}
onModuleInit() {
// Skip subscription during migration generation
if (process.env.MIGRATIONS_RUN === 'false' || !this.subscriber) {
return;
}
this.subscriber.subscribe(EventType.NOTIFICATION_CREATED, async (message) => {
const data = JSON.parse(message);
this.logger.log('Received message on NOTIFICATION_CREATED channel:', data);

View File

@ -80,7 +80,7 @@ export function formatCurrencyAmount(amount: number | string, currency: string):
/**
* Get currency from account or transaction, with fallback
* @param accountCurrency - Currency from account entity
* @param accountCurrency - Currency from account entity (may be numeric like '682')
* @param transactionCurrency - Currency from transaction entity (may be numeric)
* @param fallback - Fallback currency (default: 'SAR')
* @returns ISO currency code
@ -90,9 +90,16 @@ export function getCurrency(
transactionCurrency?: string | null,
fallback: string = 'SAR'
): string {
// Prefer account currency (already in ISO format)
// Convert account currency first (it may be numeric like '682')
if (accountCurrency) {
return accountCurrency;
const converted = numericToCurrencyCode(accountCurrency);
if (converted && converted !== accountCurrency) {
return converted; // Successfully converted from numeric to ISO
}
// If already ISO format, return as is
if (/^[A-Z]{3}$/.test(accountCurrency)) {
return accountCurrency;
}
}
// Convert transaction currency (may be numeric)

View File

@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddTimezoneFields1768395622276 implements MigrationInterface {
name = 'AddTimezoneFields1768395622276'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ADD "timezone" character varying(50)`);
await queryRunner.query(`ALTER TABLE "devices" ADD "timezone" character varying(50)`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "is_push_enabled" SET DEFAULT true`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "is_push_enabled" SET DEFAULT false`);
await queryRunner.query(`ALTER TABLE "devices" DROP COLUMN "timezone"`);
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "timezone"`);
}
}

View File

@ -11,3 +11,4 @@ export * from './1765804942393-AddKycFieldsAndTransactions';
export * from './1765877128065-AddNationalIdToKycTransactions';
export * from './1765891028260-RemoveOldCustomerColumns';
export * from './1765975126402-RemoveAddressColumns';
export * from './1768395622276-AddTimezoneFields';

View File

@ -34,4 +34,12 @@ export class UpdateUserRequestDto {
@IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) })
@IsOptional()
dateOfBirth!: Date;
@ApiPropertyOptional({
example: 'Asia/Riyadh',
description: 'User preferred timezone for reports/statements (e.g., "Asia/Riyadh", "America/New_York"). Leave empty or "Auto" to use device timezone.',
})
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'user.timezone' }) })
timezone?: string;
}

View File

@ -18,6 +18,9 @@ export class Device {
@Column('varchar', { name: 'fcm_token', nullable: true })
fcmToken?: string | null;
@Column('varchar', { name: 'timezone', nullable: true, length: 50 })
timezone?: string | null; // e.g., "Asia/Riyadh", "America/New_York" - auto-detected from device
@Column('timestamp with time zone', { name: 'last_access_on', default: () => 'CURRENT_TIMESTAMP' })
lastAccessOn!: Date;

View File

@ -67,6 +67,9 @@ export class User extends BaseEntity {
@Column({ name: 'is_sms_enabled', default: false })
isSmsEnabled!: boolean;
@Column('varchar', { name: 'timezone', nullable: true, length: 50 })
timezone?: string | null; // User's preferred timezone for reports/statements (e.g., "Asia/Riyadh")
@Column('text', { nullable: true, array: true, name: 'roles' })
roles!: Roles[];