mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2026-03-10 17:11:44 +00:00
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:
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,7 +172,7 @@ 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,
|
||||
@ -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
|
||||
}
|
||||
|
||||
// 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(card.parentId);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Could not reload parent account, using cached balance`);
|
||||
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',
|
||||
|
||||
@ -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;
|
||||
},
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,10 +90,17 @@ 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) {
|
||||
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)
|
||||
if (transactionCurrency) {
|
||||
|
||||
18
src/db/migrations/1768395622276-AddTimezoneFields.ts
Normal file
18
src/db/migrations/1768395622276-AddTimezoneFields.ts
Normal 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"`);
|
||||
}
|
||||
|
||||
}
|
||||
@ -11,3 +11,4 @@ export * from './1765804942393-AddKycFieldsAndTransactions';
|
||||
export * from './1765877128065-AddNationalIdToKycTransactions';
|
||||
export * from './1765891028260-RemoveOldCustomerColumns';
|
||||
export * from './1765975126402-RemoveAddressColumns';
|
||||
export * from './1768395622276-AddTimezoneFields';
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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[];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user