update: add new logic for push token

This commit is contained in:
mahyargdz
2025-07-28 12:35:18 +03:30
parent 1f932e6f7e
commit b683633e48
11 changed files with 427 additions and 21 deletions
+4
View File
@@ -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 {
@@ -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: {
@@ -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 };
@@ -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}`);
}
}
}
@@ -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;
}
@@ -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;
}
+3 -2
View File
@@ -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[];
//=========================
@@ -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);
+29 -7
View File
@@ -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" })