chore: add najva push notif service
This commit is contained in:
@@ -1,7 +0,0 @@
|
||||
-- Add 2FA fields to users table
|
||||
ALTER TABLE users
|
||||
ADD COLUMN is_2fa_enabled BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
ADD COLUMN two_factor_enabled_at TIMESTAMPTZ DEFAULT NULL;
|
||||
|
||||
-- Create index on is_2fa_enabled for query performance
|
||||
CREATE INDEX idx_users_is_2fa_enabled ON users (is_2fa_enabled);
|
||||
@@ -1,23 +0,0 @@
|
||||
-- Add web push notification fields to users table
|
||||
ALTER TABLE users
|
||||
ADD COLUMN push_tokens JSONB DEFAULT NULL,
|
||||
ADD COLUMN push_notifications_enabled BOOLEAN DEFAULT true,
|
||||
ADD COLUMN last_push_notification_at TIMESTAMPTZ DEFAULT NULL;
|
||||
|
||||
-- Add indexes for better performance
|
||||
CREATE INDEX IF NOT EXISTS idx_users_push_notifications_enabled ON users (push_notifications_enabled)
|
||||
WHERE
|
||||
push_notifications_enabled = true
|
||||
AND deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_push_tokens ON users USING GIN (push_tokens)
|
||||
WHERE
|
||||
push_tokens IS NOT NULL
|
||||
AND deleted_at IS NULL;
|
||||
|
||||
-- Add comment for documentation
|
||||
COMMENT ON COLUMN users.push_tokens IS 'JSON array of web push subscription objects containing endpoint and keys';
|
||||
|
||||
COMMENT ON COLUMN users.push_notifications_enabled IS 'Whether web push notifications are enabled for this user';
|
||||
|
||||
COMMENT ON COLUMN users.last_push_notification_at IS 'Timestamp of the last push notification sent to this user';
|
||||
@@ -1,5 +1,7 @@
|
||||
// export const AUTH_THROTTLE = "AUTH_THROTTLE";
|
||||
export const AI_CONFIG = "AI_CONFIG";
|
||||
export const MIKRO_ORM_QUERY_LOGGER = "MIKRO_ORM_QUERY_LOGGER";
|
||||
export const NAJVA_CONFIG = "NAJVA_CONFIG";
|
||||
export const AUTH_THROTTLE_TTL = 1 * 60 * 1000;
|
||||
export const AUTH_THROTTLE_LIMIT = 5;
|
||||
export const AUTH__REFRESH_THROTTLE_TTL = 10 * 60 * 1000;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ValueProvider } from "@nestjs/common";
|
||||
import { Logger } from "@nestjs/common";
|
||||
|
||||
import { MIKRO_ORM_QUERY_LOGGER } from "../constants";
|
||||
|
||||
export const mikroOrmQueryLoggerProvider: ValueProvider = {
|
||||
provide: MIKRO_ORM_QUERY_LOGGER, //
|
||||
useValue: new Logger("mikro-orm"),
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import { ValueProvider } from "@nestjs/common";
|
||||
import { Logger } from "@nestjs/common";
|
||||
|
||||
export const MIKRO_ORM_QUERY_LOGGER = "MikroOrmQueryLogger";
|
||||
|
||||
export const MikroOrmQueryLogger: ValueProvider = {
|
||||
provide: MIKRO_ORM_QUERY_LOGGER,
|
||||
useValue: new Logger("mikro-orm"),
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { FactoryProvider } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
import { NAJVA_CONFIG } from "../constants";
|
||||
|
||||
export interface NajvaConfigType {
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
websiteId: number;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
export const najvaConfigProvider: FactoryProvider<NajvaConfigType> = {
|
||||
provide: NAJVA_CONFIG,
|
||||
useFactory: (configService: ConfigService) => {
|
||||
return {
|
||||
apiUrl: configService.getOrThrow("NAJVA_API_URL"),
|
||||
apiKey: configService.getOrThrow("NAJVA_API_KEY"),
|
||||
websiteId: configService.getOrThrow("NAJVA_WEBSITE_ID"),
|
||||
ttl: configService.getOrThrow("PUSH_TTL"),
|
||||
};
|
||||
},
|
||||
inject: [ConfigService],
|
||||
};
|
||||
@@ -3,11 +3,12 @@ import { PostgreSqlDriver } from "@mikro-orm/postgresql";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
import { MIKRO_ORM_QUERY_LOGGER, MikroOrmQueryLogger } from "../common/providers/mikro-orm-logger";
|
||||
import { MIKRO_ORM_QUERY_LOGGER } from "../common/constants";
|
||||
import { mikroOrmQueryLoggerProvider } from "../common/providers/mikro-orm-logger.provider";
|
||||
|
||||
export const databaseConfig: MikroOrmModuleAsyncOptions = {
|
||||
inject: [ConfigService, MIKRO_ORM_QUERY_LOGGER],
|
||||
providers: [MikroOrmQueryLogger],
|
||||
providers: [mikroOrmQueryLoggerProvider],
|
||||
useFactory: (configService: ConfigService, logger: Logger) => {
|
||||
const DB_PASS = configService.getOrThrow<string>("DB_PASS");
|
||||
const DB_USER = configService.getOrThrow<string>("DB_USER");
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ConfigType, registerAs } from "@nestjs/config";
|
||||
|
||||
export const pushNotificationsConfig = registerAs("pushNotifications", () => ({
|
||||
webPush: {
|
||||
vapidPublicKey: process.env.VAPID_PUBLIC_KEY || "",
|
||||
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY || "",
|
||||
vapidSubject: process.env.VAPID_SUBJECT || "mailto:admin@danakcorp.com",
|
||||
},
|
||||
enabled: process.env.PUSH_NOTIFICATIONS_ENABLED === "true",
|
||||
retryAttempts: parseInt(process.env.PUSH_RETRY_ATTEMPTS || "3", 10),
|
||||
retryDelay: parseInt(process.env.PUSH_RETRY_DELAY || "5000", 10),
|
||||
ttl: parseInt(process.env.PUSH_TTL || "86400", 10), // 24 hours
|
||||
urgency: process.env.PUSH_URGENCY || "normal", // low, normal, high
|
||||
}));
|
||||
|
||||
export type PushNotificationsConfigType = ConfigType<typeof pushNotificationsConfig>;
|
||||
|
||||
export const pushNotificationsConfigFactory = () => ({
|
||||
imports: [],
|
||||
useFactory: (configService: ConfigService) => configService.get("pushNotifications"),
|
||||
inject: [ConfigService],
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface INajvaNotificationMessage {
|
||||
title: string;
|
||||
body: string;
|
||||
icon?: string;
|
||||
image?: string;
|
||||
notification_click: {
|
||||
click_url: string;
|
||||
};
|
||||
button_1?: {
|
||||
title: string;
|
||||
click_url: string;
|
||||
};
|
||||
button_2?: {
|
||||
title: string;
|
||||
click_url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface INajvaPushRequest {
|
||||
website_id: number;
|
||||
ttl: number;
|
||||
tokens: string[];
|
||||
message: INajvaNotificationMessage;
|
||||
}
|
||||
|
||||
export interface INajvaPushResponse {
|
||||
Message: string;
|
||||
Entries: {
|
||||
request_id: string;
|
||||
tokens: Array<{
|
||||
token: string;
|
||||
status: "Sent" | "InvalidToken";
|
||||
}>;
|
||||
};
|
||||
}
|
||||
@@ -1,26 +1,27 @@
|
||||
import { MikroOrmModule } from "@mikro-orm/nestjs";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { BullModule } from "@nestjs/bullmq";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
|
||||
import { NOTIFICATION } from "./constants";
|
||||
import { Notification } from "./entities/notification.entity";
|
||||
import { ChangePasswordHandler } from "./handlers/change-password.handler";
|
||||
import { NewEmailHandler } from "./handlers/new-email.handler";
|
||||
import { NotificationHandlerFactory } from "./handlers/notification-handler.factory";
|
||||
import { UserLoginHandler } from "./handlers/user-login.handler";
|
||||
import { NotificationController } from "./notifications.controller";
|
||||
import { NotificationProcessor } from "./queue/notification.processor";
|
||||
import { NotificationQueue } from "./queue/notification.queue";
|
||||
import { NajvaPushService } from "./services/najva-push.service";
|
||||
import { NotificationsService } from "./services/notifications.service";
|
||||
import { pushNotificationsConfig } from "../../configs/push-notifications.config";
|
||||
import { najvaConfigProvider } from "../../common/providers/najva-config.provider";
|
||||
import { NotificationSetting } from "../settings/entities/notification-setting.entity";
|
||||
import { SettingModule } from "../settings/settings.module";
|
||||
import { UtilsModule } from "../utils/utils.module";
|
||||
import { UserLoginHandler } from "./handlers/user-login.handler";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forFeature(pushNotificationsConfig),
|
||||
HttpModule,
|
||||
MikroOrmModule.forFeature([Notification, NotificationSetting]),
|
||||
BullModule.registerQueue({
|
||||
name: NOTIFICATION.QUEUE_NAME,
|
||||
@@ -40,15 +41,16 @@ import { UserLoginHandler } from "./handlers/user-login.handler";
|
||||
],
|
||||
providers: [
|
||||
NotificationsService,
|
||||
NajvaPushService,
|
||||
NotificationQueue,
|
||||
NotificationProcessor,
|
||||
// notification handlers
|
||||
NotificationHandlerFactory,
|
||||
UserLoginHandler,
|
||||
NewEmailHandler,
|
||||
ChangePasswordHandler,
|
||||
najvaConfigProvider,
|
||||
],
|
||||
controllers: [NotificationController],
|
||||
exports: [NotificationsService, NotificationQueue],
|
||||
exports: [NotificationsService, NotificationQueue, NajvaPushService],
|
||||
})
|
||||
export class NotificationModule {}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import { AxiosError } from "axios";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { NAJVA_CONFIG } from "../../../common/constants";
|
||||
import { NajvaConfigType } from "../../../common/providers/najva-config.provider";
|
||||
import { INajvaNotificationMessage, INajvaPushRequest, INajvaPushResponse } from "../interfaces/INajva";
|
||||
|
||||
@Injectable()
|
||||
export class NajvaPushService {
|
||||
private readonly logger = new Logger(NajvaPushService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(NAJVA_CONFIG) private readonly config: NajvaConfigType,
|
||||
private readonly httpService: HttpService,
|
||||
) {}
|
||||
|
||||
//===============================================
|
||||
|
||||
async sendPushNotification(tokens: string[], message: INajvaNotificationMessage): Promise<INajvaPushResponse | null> {
|
||||
if (!tokens || tokens.length === 0) {
|
||||
this.logger.warn("No tokens provided for push notification");
|
||||
return null;
|
||||
}
|
||||
|
||||
const validTokens = tokens.filter((token) => token && token.trim() !== "");
|
||||
if (validTokens.length === 0) {
|
||||
this.logger.warn("No valid tokens found for push notification");
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload: INajvaPushRequest = {
|
||||
website_id: this.config.websiteId,
|
||||
ttl: this.config.ttl,
|
||||
tokens: validTokens,
|
||||
message,
|
||||
};
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
apiKey: this.config.apiKey,
|
||||
};
|
||||
|
||||
try {
|
||||
this.logger.log(`Sending push notification to ${validTokens.length} tokens`);
|
||||
|
||||
const formData = new URLSearchParams();
|
||||
formData.append("website_id", payload.website_id.toString());
|
||||
formData.append("ttl", payload.ttl.toString());
|
||||
|
||||
validTokens.forEach((token) => {
|
||||
formData.append("tokens[]", token);
|
||||
});
|
||||
|
||||
formData.append("message.title", message.title);
|
||||
formData.append("message.body", message.body);
|
||||
|
||||
formData.append("message.notification_click.click_url", message.notification_click.click_url);
|
||||
|
||||
if (message.icon) {
|
||||
formData.append("message.icon", message.icon);
|
||||
}
|
||||
|
||||
if (message.image) {
|
||||
formData.append("message.image", message.image);
|
||||
}
|
||||
|
||||
if (message.button_1) {
|
||||
formData.append("message.button_1.title", message.button_1.title);
|
||||
formData.append("message.button_1.click_url", message.button_1.click_url);
|
||||
}
|
||||
|
||||
if (message.button_2) {
|
||||
formData.append("message.button_2.title", message.button_2.title);
|
||||
formData.append("message.button_2.click_url", message.button_2.click_url);
|
||||
}
|
||||
|
||||
const response = await firstValueFrom(this.httpService.post<INajvaPushResponse>(this.config.apiUrl, formData.toString(), { headers }));
|
||||
|
||||
this.logger.log(`Push notification sent successfully. Request ID: ${response.data.Entries.request_id}`);
|
||||
|
||||
response.data.Entries.tokens.forEach((tokenInfo) => {
|
||||
if (tokenInfo.status === "InvalidToken") {
|
||||
this.logger.warn(`Invalid token detected: ${tokenInfo.token}`);
|
||||
}
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to send push notification:", error);
|
||||
|
||||
if (error instanceof AxiosError) {
|
||||
this.logger.error(`HTTP Status: ${error.response?.status}`);
|
||||
this.logger.error(`Response: ${JSON.stringify(error.response?.data)}`);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
//===============================================
|
||||
async sendNotificationToUser(userToken: string, message: INajvaNotificationMessage): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.sendPushNotification([userToken], message);
|
||||
|
||||
if (!result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tokenResult = result.Entries.tokens.find((t) => t.token === userToken);
|
||||
return tokenResult?.status === "Sent";
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send notification to user token ${userToken}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
//===============================================
|
||||
|
||||
async sendNotificationToMultipleUsers(userTokens: string[], message: INajvaNotificationMessage): Promise<{ success: number; failed: number }> {
|
||||
try {
|
||||
const result = await this.sendPushNotification(userTokens, message);
|
||||
|
||||
if (!result) {
|
||||
return { success: 0, failed: userTokens.length };
|
||||
}
|
||||
|
||||
const successCount = result.Entries.tokens.filter((t) => t.status === "Sent").length;
|
||||
const failedCount = result.Entries.tokens.filter((t) => t.status === "InvalidToken").length;
|
||||
|
||||
return { success: successCount, failed: failedCount };
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send notifications to multiple users:`, error);
|
||||
return { success: 0, failed: userTokens.length };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { EntityManager } from "@mikro-orm/postgresql";
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
import { NajvaPushService } from "./najva-push.service";
|
||||
import { CommonMessage, NotificationMessage, UserMessage } from "../../../common/enums/message.enum";
|
||||
import { NotifType } from "../../settings/enums/notif-settings.enum";
|
||||
import { UserSettingsService } from "../../settings/services/user-settings.service";
|
||||
@@ -8,6 +10,7 @@ import { User } from "../../users/entities/user.entity";
|
||||
import { CreateNotificationDto } from "../DTO/create-notification.dto";
|
||||
import { SearchNotificationQueryDto } from "../DTO/search-notification-query.dto";
|
||||
import { Notification } from "../entities/notification.entity";
|
||||
import { INajvaNotificationMessage } from "../interfaces/INajva";
|
||||
import { IBaseNotificationData, IChangePasswordNotificationData, INewEmailNotificationData } from "../interfaces/ISendNotificationData";
|
||||
import { NotificationRepository } from "../repositories/notifications.repository";
|
||||
|
||||
@@ -18,6 +21,8 @@ export class NotificationsService {
|
||||
private readonly em: EntityManager,
|
||||
private readonly notificationRepository: NotificationRepository,
|
||||
private readonly userSettingsService: UserSettingsService,
|
||||
private readonly najvaPushService: NajvaPushService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
//************************ */
|
||||
@@ -37,6 +42,16 @@ export class NotificationsService {
|
||||
});
|
||||
await localEm.persistAndFlush(notification);
|
||||
|
||||
if (user.pushToken) {
|
||||
await this.sendPushNotificationToUser(user.pushToken, {
|
||||
title: createNotificationDto.title,
|
||||
body: createNotificationDto.message,
|
||||
notification_click: {
|
||||
click_url: `${this.configService.getOrThrow("FRONTEND_URL")}/notifications`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await localEm.commit();
|
||||
|
||||
return notification;
|
||||
@@ -59,11 +74,35 @@ export class NotificationsService {
|
||||
});
|
||||
//
|
||||
await em.persistAndFlush(notification);
|
||||
|
||||
// Send push notification if user has a push token
|
||||
if (user.pushToken) {
|
||||
await this.sendPushNotificationToUser(user.pushToken, {
|
||||
title: createNotificationDto.title,
|
||||
body: createNotificationDto.message,
|
||||
notification_click: {
|
||||
click_url: `${this.configService.getOrThrow("FRONTEND_URL")}/notifications`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
}
|
||||
|
||||
//************************ */
|
||||
//===============================================
|
||||
|
||||
async sendPushNotificationToUser(userToken: string, message: INajvaNotificationMessage): Promise<boolean> {
|
||||
return this.najvaPushService.sendNotificationToUser(userToken, message);
|
||||
}
|
||||
|
||||
//===============================================
|
||||
|
||||
async sendPushNotificationToMultipleUsers(userTokens: string[], message: INajvaNotificationMessage): Promise<{ success: number; failed: number }> {
|
||||
return this.najvaPushService.sendNotificationToMultipleUsers(userTokens, message);
|
||||
}
|
||||
|
||||
//===============================================
|
||||
|
||||
async markAsRead(notificationId: string, userId: string) {
|
||||
const notification = await this.notificationRepository.findOne({ id: notificationId, recipient: { id: userId }, deletedAt: null });
|
||||
@@ -78,7 +117,7 @@ export class NotificationsService {
|
||||
};
|
||||
}
|
||||
|
||||
//************************ */
|
||||
//===============================================
|
||||
|
||||
async getAllNotifications(queryDto: SearchNotificationQueryDto, recipientId: string) {
|
||||
const [notifications, count] = await this.notificationRepository.getAllNotifications(queryDto, recipientId);
|
||||
@@ -86,7 +125,7 @@ export class NotificationsService {
|
||||
return { notifications, notificationCount, count, paginate: true };
|
||||
}
|
||||
|
||||
//************************ */
|
||||
//===============================================
|
||||
|
||||
async getNotificationById(id: string) {
|
||||
const notification = await this.notificationRepository.findOne({ id, deletedAt: null });
|
||||
@@ -94,14 +133,14 @@ export class NotificationsService {
|
||||
return { notification };
|
||||
}
|
||||
|
||||
//************************ */
|
||||
//===============================================
|
||||
|
||||
async countUserNotifications(userId: string) {
|
||||
const notificationCount = await this.notificationRepository.count({ recipient: { id: userId }, isRead: false, deletedAt: null });
|
||||
return notificationCount;
|
||||
}
|
||||
|
||||
//************************ */
|
||||
//===============================================
|
||||
|
||||
async markAllAsRead(userId: string) {
|
||||
await this.notificationRepository.nativeUpdate({ recipient: { id: userId }, deletedAt: null }, { isRead: true });
|
||||
@@ -110,7 +149,7 @@ export class NotificationsService {
|
||||
};
|
||||
}
|
||||
|
||||
//************************ */
|
||||
//===============================================
|
||||
|
||||
async createLoginNotification(recipientId: string, _data: IBaseNotificationData) {
|
||||
const loginDate = new Date().toLocaleString("fa-IR");
|
||||
@@ -121,7 +160,7 @@ export class NotificationsService {
|
||||
return this.createNotification({ title: NotificationMessage.LOGIN, type: NotifType.USER_LOGIN, message, recipientId });
|
||||
}
|
||||
}
|
||||
//************************ */
|
||||
//===============================================
|
||||
|
||||
async createNewEmailNotification(recipientId: string, data: INewEmailNotificationData, em: EntityManager) {
|
||||
const message = NotificationMessage.NEW_EMAIL_MESSAGE.replace("[subject]", data.subject);
|
||||
@@ -130,7 +169,7 @@ export class NotificationsService {
|
||||
return this.createNotification({ title: NotificationMessage.NEW_EMAIL, type: NotifType.NEW_EMAIL, message, recipientId }, em);
|
||||
}
|
||||
}
|
||||
//************************ */
|
||||
//===============================================
|
||||
|
||||
async changePasswordNotification(recipientId: string, _data: IChangePasswordNotificationData, em: EntityManager) {
|
||||
const message = NotificationMessage.CHANGE_PASSWORD_MESSAGE;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsOptional, IsUrl } from "class-validator";
|
||||
import { IsNotEmpty, IsOptional, IsString, IsUrl } from "class-validator";
|
||||
|
||||
import { UserMessage } from "../../../common/enums/message.enum";
|
||||
|
||||
@@ -9,4 +9,9 @@ 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;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,9 @@ export class User extends BaseEntity {
|
||||
@Property({ type: "timestamptz", nullable: true })
|
||||
twoFactorEnabledAt?: Date;
|
||||
|
||||
@Property({ type: "varchar", nullable: true })
|
||||
pushToken?: string;
|
||||
|
||||
//=========================
|
||||
|
||||
@OneToMany(() => RefreshToken, (token) => token.user)
|
||||
|
||||
Reference in New Issue
Block a user