From b683633e48f0d6589c3b8baa3e3e9b6b358d76bf Mon Sep 17 00:00:00 2001 From: mahyargdz Date: Mon, 28 Jul 2025 12:35:18 +0330 Subject: [PATCH] update: add new logic for push token --- PUSH_NOTIFICATIONS_MULTI_DEVICE.md | 214 ++++++++++++++++++ .../update_push_tokens_to_array.sql | 28 +++ src/common/enums/message.enum.ts | 4 + .../notifications/notifications.module.ts | 3 +- .../services/najva-push.service.ts | 3 +- .../services/notifications.service.ts | 47 +++- .../users/DTO/manage-push-token.dto.ts | 21 ++ .../users/DTO/update-user-profile.dto.ts | 7 +- src/modules/users/entities/user.entity.ts | 5 +- src/modules/users/services/users.service.ts | 80 +++++++ src/modules/users/users.controller.ts | 36 ++- 11 files changed, 427 insertions(+), 21 deletions(-) create mode 100644 PUSH_NOTIFICATIONS_MULTI_DEVICE.md create mode 100644 database/migrations/update_push_tokens_to_array.sql create mode 100644 src/modules/users/DTO/manage-push-token.dto.ts diff --git a/PUSH_NOTIFICATIONS_MULTI_DEVICE.md b/PUSH_NOTIFICATIONS_MULTI_DEVICE.md new file mode 100644 index 0000000..76e6d79 --- /dev/null +++ b/PUSH_NOTIFICATIONS_MULTI_DEVICE.md @@ -0,0 +1,214 @@ +# Multi-Device Push Notifications Implementation + +## Overview + +Updated the push notification system to support multiple devices per user. Users can now receive push notifications on all their logged-in devices (mobile, tablet, desktop, etc.). + +## Database Changes + +### Migration: `update_push_tokens_to_array.sql` + +- Converted single `push_token` field to `push_tokens` JSON array +- Automatically migrates existing single tokens to array format +- Adds optimized GIN index for JSON array queries + +## API Endpoints + +### 1. Add Push Token (New Device Login) + +```http +POST /users/push-token +Authorization: Bearer +Content-Type: application/json + +{ + "pushToken": "device_push_token_from_najva_sdk", + "deviceId": "optional_device_identifier" +} +``` + +### 2. Remove Push Token (Device Logout) + +```http +DELETE /users/push-token +Authorization: Bearer +Content-Type: application/json + +{ + "pushToken": "device_push_token_to_remove" +} +``` + +### 3. Get All Push Tokens + +```http +GET /users/push-tokens +Authorization: Bearer +``` + +Response: + +```json +{ + "pushTokens": ["token1", "token2", "token3"], + "totalTokens": 3 +} +``` + +### 4. Update Profile (Push Token Removed) + +```http +PATCH /users/profile +Authorization: Bearer +Content-Type: application/json + +{ + "profilePic": "https://example.com/profile.jpg" +} +``` + +## Features + +### ✅ Multi-Device Support + +- Users can have multiple push tokens (one per device) +- Notifications sent to all registered devices simultaneously +- Automatic deduplication prevents duplicate tokens + +### ✅ Invalid Token Cleanup + +- Automatically detects invalid/expired tokens from Najva API response +- Removes invalid tokens from user's token list +- Logs cleanup activities for monitoring + +### ✅ Device Management + +- Add tokens when user logs in on new device +- Remove tokens when user logs out from device +- View all registered devices/tokens + +### ✅ Backward Compatibility + +- Existing single token data automatically migrated to array format +- No breaking changes to existing notification flow + +## Implementation Details + +### User Entity Changes + +```typescript +// Before +@Property({ type: "varchar", nullable: true }) +pushToken?: string; + +// After +@Property({ type: "json", nullable: true }) +pushTokens?: string[]; +``` + +### Service Methods + +```typescript +// Add token for new device +await usersService.addPushToken(userId, { pushToken: "token" }); + +// Remove token when logging out +await usersService.removePushToken(userId, { pushToken: "token" }); + +// Send to all user devices +await notificationsService.sendPushNotificationToUserDevices(userId, userTokens, message); +``` + +### Automatic Token Cleanup + +When sending notifications, invalid tokens are automatically: + +1. Detected from Najva API response +2. Logged for monitoring +3. Removed from user's token array +4. Database updated to clean state + +## Frontend Integration + +### When User Logs In + +```javascript +// Get push token from Najva SDK +const pushToken = await najva.getPushToken(); + +// Register token for this device +await fetch("/api/users/push-token", { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + pushToken: pushToken, + deviceId: navigator.userAgent, // optional + }), +}); +``` + +### When User Logs Out + +```javascript +// Remove token for this device +await fetch("/api/users/push-token", { + method: "DELETE", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + pushToken: currentDeviceToken, + }), +}); +``` + +## Environment Variables + +```env +NAJVA_API_KEY=your_najva_api_key +NAJVA_WEBSITE_ID=your_website_id +NAJVA_API_URL=https://push.najva.com/v1/send/token/ +PUSH_TTL=24 +FRONTEND_URL=https://your-frontend-domain.com +``` + +## Benefits + +1. **Multi-Device Support**: Users get notifications on all their devices +2. **Device Management**: Add/remove devices as needed +3. **Automatic Cleanup**: Invalid tokens automatically removed +4. **Scalable**: Efficient JSON array storage with GIN indexing +5. **Reliable**: Handles edge cases like duplicate tokens and API failures +6. **Monitoring**: Comprehensive logging for debugging and monitoring + +## Migration Steps + +1. **Run Database Migration**: + + ```sql + -- Execute: database/migrations/update_push_tokens_to_array.sql + ``` + +2. **Update Frontend**: + - Use new `/users/push-token` endpoints + - Remove `pushToken` field from profile updates + +3. **Deploy Backend**: + - All changes are backward compatible + - Existing tokens automatically migrated + +## Testing + +Test the multi-device functionality: + +1. Login from Device A → Add token A +2. Login from Device B → Add token B +3. Create notification → Both devices receive push +4. Logout from Device A → Remove token A +5. Create notification → Only device B receives push + +The system now properly supports users accessing your application from multiple devices while maintaining reliable push notification delivery! diff --git a/database/migrations/update_push_tokens_to_array.sql b/database/migrations/update_push_tokens_to_array.sql new file mode 100644 index 0000000..15714ac --- /dev/null +++ b/database/migrations/update_push_tokens_to_array.sql @@ -0,0 +1,28 @@ +-- Update push notification token field to support multiple devices +-- Step 1: Add new push_tokens JSON array column +ALTER TABLE users ADD COLUMN push_tokens JSONB DEFAULT NULL; + +-- Step 2: Migrate existing single push_token data to the new array format +UPDATE users +SET + push_tokens = CASE + WHEN push_token IS NOT NULL + AND push_token != '' THEN jsonb_build_array (push_token) + ELSE NULL + END +WHERE + push_token IS NOT NULL; + +-- Step 3: Drop the old single push_token column +ALTER TABLE users DROP COLUMN IF EXISTS push_token; + +-- Step 4: Add index for better performance on push_tokens +CREATE INDEX IF NOT EXISTS idx_users_push_tokens ON users USING GIN (push_tokens) +WHERE + push_tokens IS NOT NULL + AND push_tokens != 'null'::jsonb + AND jsonb_array_length(push_tokens) > 0 + AND deleted_at IS NULL; + +-- Step 5: Add comment for documentation +COMMENT ON COLUMN users.push_tokens IS 'JSON array of Najva push notification tokens from different user devices'; \ No newline at end of file diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 9b75dec..a67fdb1 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -110,6 +110,10 @@ export const enum UserMessage { PROFILE_PICTURE_URL = "آدرس تصویر پروفایل باید یک آدرس اینترنتی معتبر باشد", USER_UPDATED_SUCCESSFULLY = "کاربر با موفقیت به روز رسانی شد", PROFILE_PICTURE_NOT_EMPTY = "تصویر پروفایل نمیتواند خالی باشد", + PUSH_TOKEN_NOT_FOUND = "توکن پوش نامعتبر است", + PUSH_TOKEN_ADDED_SUCCESSFULLY = "توکن پوش با موفقیت اضافه شد", + NO_PUSH_TOKENS_FOUND = "توکن پوشی برای این کاربر یافت نشد", + PUSH_TOKEN_REMOVED_SUCCESSFULLY = "توکن پوش با موفقیت حذف شد", } export const enum CommonMessage { diff --git a/src/modules/notifications/notifications.module.ts b/src/modules/notifications/notifications.module.ts index b298323..47e86ad 100755 --- a/src/modules/notifications/notifications.module.ts +++ b/src/modules/notifications/notifications.module.ts @@ -17,12 +17,13 @@ import { NotificationsService } from "./services/notifications.service"; import { najvaConfigProvider } from "../../common/providers/najva-config.provider"; import { NotificationSetting } from "../settings/entities/notification-setting.entity"; import { SettingModule } from "../settings/settings.module"; +import { User } from "../users/entities/user.entity"; import { UtilsModule } from "../utils/utils.module"; @Module({ imports: [ HttpModule, - MikroOrmModule.forFeature([Notification, NotificationSetting]), + MikroOrmModule.forFeature([Notification, NotificationSetting, User]), BullModule.registerQueue({ name: NOTIFICATION.QUEUE_NAME, defaultJobOptions: { diff --git a/src/modules/notifications/services/najva-push.service.ts b/src/modules/notifications/services/najva-push.service.ts index a3071d9..f43ca96 100644 --- a/src/modules/notifications/services/najva-push.service.ts +++ b/src/modules/notifications/services/najva-push.service.ts @@ -127,8 +127,9 @@ export class NajvaPushService { const successCount = result.Entries.tokens.filter((t) => t.status === "Sent").length; const failedCount = result.Entries.tokens.filter((t) => t.status === "InvalidToken").length; + const invalidTokens = result.Entries.tokens.filter((t) => t.status === "InvalidToken").map((t) => t.token); - return { success: successCount, failed: failedCount }; + return { success: successCount, failed: failedCount, invalidTokens }; } catch (error) { this.logger.error(`Failed to send notifications to multiple users:`, error); return { success: 0, failed: userTokens.length }; diff --git a/src/modules/notifications/services/notifications.service.ts b/src/modules/notifications/services/notifications.service.ts index 2716988..0a3ad9b 100755 --- a/src/modules/notifications/services/notifications.service.ts +++ b/src/modules/notifications/services/notifications.service.ts @@ -7,6 +7,7 @@ import { CommonMessage, NotificationMessage, UserMessage } from "../../../common import { NotifType } from "../../settings/enums/notif-settings.enum"; import { UserSettingsService } from "../../settings/services/user-settings.service"; import { User } from "../../users/entities/user.entity"; +import { UserRepository } from "../../users/repositories/user.repository"; import { CreateNotificationDto } from "../DTO/create-notification.dto"; import { SearchNotificationQueryDto } from "../DTO/search-notification-query.dto"; import { Notification } from "../entities/notification.entity"; @@ -23,6 +24,7 @@ export class NotificationsService { private readonly userSettingsService: UserSettingsService, private readonly najvaPushService: NajvaPushService, private readonly configService: ConfigService, + private readonly userRepository: UserRepository, ) {} //************************ */ @@ -42,8 +44,9 @@ export class NotificationsService { }); await localEm.persistAndFlush(notification); - if (user.pushToken) { - await this.sendPushNotificationToUser(user.pushToken, { + // Send push notification to all user devices if user has push tokens + if (user.pushTokens && user.pushTokens.length > 0) { + await this.sendPushNotificationToUserDevices(user.id, user.pushTokens, { title: createNotificationDto.title, body: createNotificationDto.message, notification_click: { @@ -75,8 +78,9 @@ export class NotificationsService { await em.persistAndFlush(notification); - if (user.pushToken) { - await this.sendPushNotificationToUser(user.pushToken, { + // Send push notification to all user devices if user has push tokens + if (user.pushTokens && user.pushTokens.length > 0) { + await this.sendPushNotificationToUserDevices(user.id, user.pushTokens, { title: createNotificationDto.title, body: createNotificationDto.message, notification_click: { @@ -103,6 +107,25 @@ export class NotificationsService { //=============================================== + async sendPushNotificationToUserDevices(userId: string, userTokens: string[], message: INajvaNotificationMessage) { + try { + const result = await this.najvaPushService.sendNotificationToMultipleUsers(userTokens, message); + + if (result.invalidTokens && result.invalidTokens.length > 0) { + this.logger.warn(`Found ${result.invalidTokens.length} invalid tokens for user ${userId}: ${result.invalidTokens.join(", ")}`); + + await this.cleanupInvalidPushTokens(userId, result.invalidTokens); + } + + return result; + } catch (error) { + this.logger.error(`Failed to send push notifications to user devices:`, error); + return { success: 0, failed: userTokens.length }; + } + } + + //=============================================== + async markAsRead(notificationId: string, userId: string) { const notification = await this.notificationRepository.findOne({ id: notificationId, recipient: { id: userId }, deletedAt: null }); @@ -177,4 +200,20 @@ export class NotificationsService { return this.createNotification({ title: NotificationMessage.CHANGE_PASSWORD, type: NotifType.CHANGE_PASSWORD, message, recipientId }, em); } } + + //=============================================== + private async cleanupInvalidPushTokens(userId: string, invalidTokens: string[]) { + if (!invalidTokens || invalidTokens.length === 0) return; + + const user = await this.userRepository.findOne({ id: userId, deletedAt: null }); + if (!user || !user.pushTokens) return; + + const initialLength = user.pushTokens.length; + user.pushTokens = user.pushTokens.filter((token) => !invalidTokens.includes(token)); + + if (user.pushTokens.length !== initialLength) { + await this.em.flush(); + this.logger.log(`Cleaned up ${initialLength - user.pushTokens.length} invalid push tokens for user ${userId}`); + } + } } diff --git a/src/modules/users/DTO/manage-push-token.dto.ts b/src/modules/users/DTO/manage-push-token.dto.ts new file mode 100644 index 0000000..2395710 --- /dev/null +++ b/src/modules/users/DTO/manage-push-token.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsNotEmpty, IsOptional, IsString } from "class-validator"; + +export class AddPushTokenDto { + @IsString({ message: "Push notification token must be a string" }) + @IsNotEmpty({ message: "Push notification token is required" }) + @ApiProperty({ description: "Push notification token from frontend device", example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }) + pushToken: string; + + @IsOptional() + @IsString({ message: "Device identifier must be a string" }) + @ApiPropertyOptional({ description: "Device identifier (optional, for tracking)", example: "mobile-chrome-android" }) + deviceId?: string; +} + +export class RemovePushTokenDto { + @IsString({ message: "Push notification token must be a string" }) + @IsNotEmpty({ message: "Push notification token is required" }) + @ApiProperty({ description: "Push notification token to remove", example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }) + pushToken!: string; +} diff --git a/src/modules/users/DTO/update-user-profile.dto.ts b/src/modules/users/DTO/update-user-profile.dto.ts index 6764a71..45ef79d 100644 --- a/src/modules/users/DTO/update-user-profile.dto.ts +++ b/src/modules/users/DTO/update-user-profile.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsNotEmpty, IsOptional, IsString, IsUrl } from "class-validator"; +import { IsNotEmpty, IsOptional, IsUrl } from "class-validator"; import { UserMessage } from "../../../common/enums/message.enum"; @@ -9,9 +9,4 @@ export class UpdateUserProfileDto { @IsUrl({ protocols: ["https"] }, { message: UserMessage.PROFILE_PICTURE_URL }) @ApiPropertyOptional({ description: "Profile picture URL", example: "https://example.com/profile.jpg" }) profilePic?: string; - - @IsOptional() - @IsString() - @ApiPropertyOptional({ description: "Push notification token from frontend", example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }) - pushToken?: string; } diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index 8c88eb4..3cd0357 100644 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -58,8 +58,9 @@ export class User extends BaseEntity { @Property({ type: "timestamptz", nullable: true }) twoFactorEnabledAt?: Date; - @Property({ type: "varchar", nullable: true }) - pushToken?: string; + // Push notification tokens from different devices + @Property({ type: "json", nullable: true }) + pushTokens?: string[]; //========================= diff --git a/src/modules/users/services/users.service.ts b/src/modules/users/services/users.service.ts index e5e14dd..8e1ba6d 100644 --- a/src/modules/users/services/users.service.ts +++ b/src/modules/users/services/users.service.ts @@ -15,6 +15,7 @@ import { UserSettingsService } from "../../settings/services/user-settings.servi import { Template } from "../../templates/entities/template.entity"; import { PasswordService } from "../../utils/services/password.service"; import { CreateEmailUserDto } from "../DTO/create-email-user.dto"; +import { AddPushTokenDto, RemovePushTokenDto } from "../DTO/manage-push-token.dto"; import { UpdateEmailUserDto } from "../DTO/update-email-user.dto"; import { UpdateUserProfileDto } from "../DTO/update-user-profile.dto"; import { UserListQueryDto } from "../DTO/user-list-query.dto"; @@ -92,6 +93,85 @@ export class UsersService { } /*******************************/ + async addPushToken(userId: string, addPushTokenDto: AddPushTokenDto) { + const user = await this.userRepository.findOne({ id: userId, deletedAt: null }); + if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); + + if (!user.pushTokens) { + user.pushTokens = []; + } + + if (!user.pushTokens.includes(addPushTokenDto.pushToken)) { + user.pushTokens.push(addPushTokenDto.pushToken); + await this.em.flush(); + + this.logger.log(`Push token added for user ${userId}. Total tokens: ${user.pushTokens.length}`); + } + + return { + message: UserMessage.PUSH_TOKEN_ADDED_SUCCESSFULLY, + totalTokens: user.pushTokens.length, + }; + } + + /*******************************/ + + async removePushToken(userId: string, removePushTokenDto: RemovePushTokenDto) { + const user = await this.userRepository.findOne({ id: userId, deletedAt: null }); + if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); + + if (!user.pushTokens || user.pushTokens.length === 0) { + throw new BadRequestException(UserMessage.NO_PUSH_TOKENS_FOUND); + } + + const initialLength = user.pushTokens.length; + user.pushTokens = user.pushTokens.filter((token) => token !== removePushTokenDto.pushToken); + + if (user.pushTokens.length === initialLength) { + throw new BadRequestException(UserMessage.PUSH_TOKEN_NOT_FOUND); + } + + await this.em.flush(); + + this.logger.log(`Push token removed for user ${userId}. Remaining tokens: ${user.pushTokens.length}`); + + return { + message: UserMessage.PUSH_TOKEN_REMOVED_SUCCESSFULLY, + totalTokens: user.pushTokens.length, + }; + } + + /*******************************/ + + async getPushTokens(userId: string) { + const user = await this.userRepository.findOne({ id: userId, deletedAt: null }); + if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); + + return { + pushTokens: user.pushTokens || [], + totalTokens: user.pushTokens?.length || 0, + }; + } + + /*******************************/ + + async cleanupInvalidPushTokens(userId: string, invalidTokens: string[]) { + if (!invalidTokens || invalidTokens.length === 0) return; + + const user = await this.userRepository.findOne({ id: userId, deletedAt: null }); + if (!user || !user.pushTokens) return; + + const initialLength = user.pushTokens.length; + user.pushTokens = user.pushTokens.filter((token) => !invalidTokens.includes(token)); + + if (user.pushTokens.length !== initialLength) { + await this.em.flush(); + this.logger.log(`Cleaned up ${initialLength - user.pushTokens.length} invalid push tokens for user ${userId}`); + } + } + + /*******************************/ + async findOneByIdWithEntityManager(id: string, em: EntityManager) { const user = await em.findOne(User, { id }, { populate: ["role"] }); if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index 470b616..35569ff 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseIntercepto import { ApiOperation, ApiResponse } from "@nestjs/swagger"; import { CreateEmailUserDto } from "./DTO/create-email-user.dto"; +import { AddPushTokenDto, RemovePushTokenDto } from "./DTO/manage-push-token.dto"; import { UpdateEmailUserDto } from "./DTO/update-email-user.dto"; import { UpdateUserProfileDto } from "./DTO/update-user-profile.dto"; import { UserListQueryDto } from "./DTO/user-list-query.dto"; @@ -24,6 +25,34 @@ export class UsersController { return this.usersService.getMe(userId); } + @Patch("profile") + @ApiOperation({ summary: "Update current user profile" }) + @ApiResponse({ status: 200, description: "Current user updated successfully" }) + updateProfile(@UserDec("id") userId: string, @Body() updateMeDto: UpdateUserProfileDto) { + return this.usersService.updateProfile(userId, updateMeDto); + } + + @Post("push-token") + @ApiOperation({ summary: "Add push notification token for current device" }) + @ApiResponse({ status: 200, description: "Push token added successfully" }) + addPushToken(@UserDec("id") userId: string, @Body() addPushTokenDto: AddPushTokenDto) { + return this.usersService.addPushToken(userId, addPushTokenDto); + } + + @Delete("push-token") + @ApiOperation({ summary: "Remove push notification token for current device" }) + @ApiResponse({ status: 200, description: "Push token removed successfully" }) + removePushToken(@UserDec("id") userId: string, @Body() removePushTokenDto: RemovePushTokenDto) { + return this.usersService.removePushToken(userId, removePushTokenDto); + } + + @Get("push-tokens") + @ApiOperation({ summary: "Get all push tokens for current user" }) + @ApiResponse({ status: 200, description: "Push tokens retrieved successfully" }) + getPushTokens(@UserDec("id") userId: string) { + return this.usersService.getPushTokens(userId); + } + @Patch(":id") @UseInterceptors(BusinessInterceptor) @ApiOperation({ summary: "Update email user" }) @@ -32,13 +61,6 @@ export class UsersController { return this.usersService.updateEmailUser(paramDto.id, businessId, body); } - @Patch("profile") - @ApiOperation({ summary: "Update current user" }) - @ApiResponse({ status: 200, description: "Current user updated successfully" }) - updateProfile(@UserDec("id") userId: string, @Body() updateMeDto: UpdateUserProfileDto) { - return this.usersService.updateProfile(userId, updateMeDto); - } - @Post() @UseInterceptors(BusinessInterceptor) @ApiOperation({ summary: "Create a new email user" })