diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 5574c795..c1733033 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -72,7 +72,7 @@ export default () => ({ apn: { url: process.env.APN_URL, secret: process.env.APN_SECRET, - keyId: process.env.APN_KEYID, + keyId: process.env.APN_KEY_ID, teamId: process.env.APN_TEAM_ID, bundleId: process.env.APN_BUNDLE_ID, }, diff --git a/src/externals/apn/apn.module.ts b/src/externals/apn/apn.module.ts index 0dde0bfe..cd695315 100644 --- a/src/externals/apn/apn.module.ts +++ b/src/externals/apn/apn.module.ts @@ -1,4 +1,3 @@ -// src/externals/apn/apn.module.ts import { Module } from '@nestjs/common'; import { ApnService } from './apn.service'; import { ConfigModule } from '@nestjs/config'; diff --git a/src/externals/apn/apn.service.ts b/src/externals/apn/apn.service.ts index c0e4ca02..1bbd6ca2 100644 --- a/src/externals/apn/apn.service.ts +++ b/src/externals/apn/apn.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as http2 from 'http2'; import jwt, { JwtHeader } from 'jsonwebtoken'; @@ -6,8 +6,8 @@ import { ApnAlert } from './apn.types'; @Injectable() export class ApnService { - private static instance: ApnService; - private configService: ConfigService; + private readonly logger = new Logger(ApnService.name); + private client: http2.ClientHttp2Session; private readonly maxReconnectAttempts = 3; private reconnectAttempts = 0; @@ -22,56 +22,44 @@ export class ApnService { private apnSecret: string; private apnKeyId: string; private apnTeamId: string; + private bundleId: string; + private apnUrl: string; constructor( - { - topic, - secret, - keyId, - teamId, - }: { - topic: string; - secret: string; - keyId: string; - teamId: string; - }, - configService: ConfigService, + @Inject(ConfigService) + private configService: ConfigService, ) { - this.configService = configService; + this.apnSecret = this.configService.get('apn.secret'); + this.apnKeyId = this.configService.get('apn.keyId'); + this.apnTeamId = this.configService.get('apn.teamId'); + this.bundleId = this.configService.get('apn.bundleId'); + this.apnUrl = this.configService.get('apn.url'); + this.client = this.connectToAPN(); this.schedulePing(); - - this.topic = topic; - this.apnSecret = secret; - this.apnKeyId = keyId; - this.apnTeamId = teamId; } private connectToAPN(): http2.ClientHttp2Session { - const apnSecret = this.configService.get('apn.secret'); - const apnKeyId = this.configService.get('apn.keyId'); - const apnTeamId = this.configService.get('apn.teamId'); - const apnUrl = this.configService.get('apn.url'); - - if (!apnSecret || !apnKeyId || !apnTeamId || !apnUrl) { - Logger.warn('APN env variables are not defined'); + if (!this.apnSecret || !this.apnKeyId || !this.apnTeamId || !this.apnUrl) { + this.logger.warn('APN env variables are not defined'); + return null; } - const client = http2.connect(apnUrl); + const client = http2.connect(this.apnUrl); client.on('error', (err) => { - Logger.error('APN connection error:', err.message); - Logger.error('Error stack:', err.stack); - Logger.error('Full error object:', err); + this.logger.error('APN connection error:', err.message); + this.logger.error('Error stack:', err.stack); + this.logger.error('Full error object:', err); }); client.on('close', () => { - Logger.warn('APN connection was closed'); + this.logger.warn('APN connection was closed'); this.handleReconnect(); }); client.on('connect', () => { this.reconnectAttempts = 0; this.lastActivity = Date.now(); - Logger.log('Connected to APN'); + this.logger.log('Connected to APN'); }); return client; @@ -82,9 +70,15 @@ export class ApnService { payload: ApnAlert, userUuid?: string, isStorageNotification = false, - ) { + ): Promise<{ + statusCode: number; + body: string; + } | null> { return new Promise((resolve, reject) => { if (!this.client || this.client.closed) { + this.logger.warn( + 'APN client session is closed, attempting to reconnect', + ); this.client = this.connectToAPN(); } @@ -92,12 +86,11 @@ export class ApnService { [http2.constants.HTTP2_HEADER_METHOD]: 'POST', [http2.constants.HTTP2_HEADER_PATH]: `/3/device/${deviceToken}`, [http2.constants.HTTP2_HEADER_SCHEME]: 'https', - [http2.constants.HTTP2_HEADER_AUTHORITY]: - this.configService.get('apn.url'), + [http2.constants.HTTP2_HEADER_AUTHORITY]: this.apnUrl, [http2.constants.HTTP2_HEADER_CONTENT_TYPE]: 'application/json', [http2.constants.HTTP2_HEADER_AUTHORIZATION]: `bearer ${this.generateJwt()}`, - 'apns-topic': this.topic, + 'apns-topic': `${this.bundleId}.pushkit.fileprovider`, }; const req = this.client.request({ ...headers }); @@ -147,6 +140,7 @@ export class ApnService { private generateJwt(): string { if (this.jwt && Date.now() - this.jwtGeneratedAt < 3500 * 1000) { + // 3500 seconds to add buffer return this.jwt; } @@ -175,7 +169,7 @@ export class ApnService { if (this.reconnectAttempts < this.maxReconnectAttempts) { setTimeout( () => { - Logger.log( + this.logger.log( `Attempting to reconnect to APN (#${this.reconnectAttempts + 1})`, ); if (this.client && !this.client.closed) { @@ -187,7 +181,7 @@ export class ApnService { this.reconnectDelay * Math.pow(2, this.reconnectAttempts), ); } else { - Logger.error('Maximum APN reconnection attempts reached'); + this.logger.error('Maximum APN reconnection attempts reached'); } } @@ -203,9 +197,9 @@ export class ApnService { if (this.client && !this.client.closed) { this.client.ping((err) => { if (err) { - Logger.error('APN PING error', err); + this.logger.error('APN PING error', err); } else { - Logger.log('APN PING sent successfully'); + this.logger.log('APN PING sent successfully'); this.lastActivity = Date.now(); } }); diff --git a/src/externals/notifications/notifications.module.ts b/src/externals/notifications/notifications.module.ts index ebc5e99a..7148442e 100644 --- a/src/externals/notifications/notifications.module.ts +++ b/src/externals/notifications/notifications.module.ts @@ -10,8 +10,22 @@ import { ShareLinkListener } from './listeners/share-link.listener'; import { AuthListener } from './listeners/auth.listener'; import { NewsletterService } from '../newsletter'; import { StorageNotificationService } from './storage.notifications.service'; +import { ApnModule } from '../apn/apn.module'; +import { + SequelizeUserRepository, + UserModel, +} from '../../modules/user/user.repository'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { UserNotificationTokensModel } from '../../modules/user/user-notification-tokens.model'; + @Module({ - imports: [ConfigModule, HttpClientModule, MailerModule], + imports: [ + ConfigModule, + HttpClientModule, + MailerModule, + SequelizeModule.forFeature([UserModel, UserNotificationTokensModel]), + ApnModule, + ], controllers: [], providers: [ NotificationService, @@ -22,6 +36,7 @@ import { StorageNotificationService } from './storage.notifications.service'; ShareLinkListener, AuthListener, NewsletterService, + SequelizeUserRepository, ], exports: [NotificationService, StorageNotificationService], }) diff --git a/src/externals/notifications/storage.notifications.service.ts b/src/externals/notifications/storage.notifications.service.ts index d79b21de..6818520c 100644 --- a/src/externals/notifications/storage.notifications.service.ts +++ b/src/externals/notifications/storage.notifications.service.ts @@ -1,7 +1,9 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { NotificationService } from './notification.service'; import { User } from '../../modules/user/user.domain'; import { NotificationEvent } from './events/notification.event'; +import { ApnService } from '../apn/apn.service'; +import { SequelizeUserRepository } from '../../modules/user/user.repository'; enum StorageEvents { FILE_CREATED = 'FILE_CREATED', @@ -19,7 +21,11 @@ interface EventArguments { @Injectable() export class StorageNotificationService { - constructor(private notificationService: NotificationService) {} + constructor( + private notificationService: NotificationService, + private apnService: ApnService, + private userRepository: SequelizeUserRepository, + ) {} fileCreated({ payload, user, clientId }: EventArguments) { const event = new NotificationEvent( @@ -32,6 +38,7 @@ export class StorageNotificationService { ); this.notificationService.add(event); + this.getTokensAndSendNotification(user.uuid); } fileUpdated({ payload, user, clientId }: EventArguments) { @@ -45,6 +52,7 @@ export class StorageNotificationService { ); this.notificationService.add(event); + this.getTokensAndSendNotification(user.uuid); } folderCreated({ payload, user, clientId }: EventArguments) { @@ -58,6 +66,7 @@ export class StorageNotificationService { ); this.notificationService.add(event); + this.getTokensAndSendNotification(user.uuid); } folderUpdated({ payload, user, clientId }: EventArguments) { @@ -71,6 +80,7 @@ export class StorageNotificationService { ); this.notificationService.add(event); + this.getTokensAndSendNotification(user.uuid); } itemsTrashed({ payload, user, clientId }: EventArguments) { @@ -84,5 +94,42 @@ export class StorageNotificationService { ); this.notificationService.add(event); + this.getTokensAndSendNotification(user.uuid); + } + + public async getTokensAndSendNotification(userUuid: string) { + console.log({ userUuid }); + const tokens = await this.userRepository.getNotificationTokens(userUuid, { + type: 'macos', + }); + + const tokenPromises = tokens.map(async ({ token }: { token: string }) => { + try { + const response = await this.apnService.sendNotification( + token, + {}, + userUuid, + true, + ); + return response.statusCode === 410 ? token : null; + } catch (error) { + Logger.error( + `Error sending APN notification to ${userUuid}: ${ + (error as Error).message + }`, + ); + } + }); + + const results = await Promise.all(tokenPromises); + + const expiredTokens = results.filter((token) => token !== null); + + if (expiredTokens.length > 0) { + await this.userRepository.deleteUserNotificationTokens( + userUuid, + expiredTokens, + ); + } } } diff --git a/src/modules/user/user.repository.ts b/src/modules/user/user.repository.ts index 5bc43ada..5d4009ae 100644 --- a/src/modules/user/user.repository.ts +++ b/src/modules/user/user.repository.ts @@ -33,7 +33,14 @@ export interface UserRepository { getMeetClosedBetaUsers(): Promise; setRoomToBetaUser(room: string, user: User): Promise; getBetaUserFromRoom(room: string): Promise; - getNotificationTokens(userId: string): Promise; + getNotificationTokens( + userId: string, + where?: Partial>, + ): Promise; + deleteUserNotificationTokens( + userUuid: UserAttributes['uuid'], + tokens: string[], + ): Promise; getNotificationTokenCount(userId: string): Promise; } @@ -213,6 +220,20 @@ export class SequelizeUserRepository implements UserRepository { return tokens.map((token) => UserNotificationTokens.build(token.toJSON())); } + async deleteUserNotificationTokens( + userUuid: UserAttributes['uuid'], + tokens: string[], + ) { + await this.modelUserNotificationTokens.destroy({ + where: { + userId: userUuid, + token: { + [Op.in]: tokens, + }, + }, + }); + } + async addNotificationToken( userId: string, token: string, diff --git a/src/modules/user/user.usecase.ts b/src/modules/user/user.usecase.ts index 5c54c7ed..ce75fc2e 100644 --- a/src/modules/user/user.usecase.ts +++ b/src/modules/user/user.usecase.ts @@ -72,7 +72,6 @@ import { SequelizeFeatureLimitsRepository } from '../feature-limit/feature-limit import { SequelizeWorkspaceRepository } from '../workspaces/repositories/workspaces.repository'; import { UserNotificationTokens } from './user-notification-tokens.domain'; import { RegisterNotificationTokenDto } from './dto/register-notification-token.dto'; -import { ApnService } from 'src/externals/apn/apn.service'; class ReferralsNotAvailableError extends Error { constructor() { @@ -151,7 +150,6 @@ export class UserUseCases { private readonly mailerService: MailerService, private readonly mailLimitRepository: SequelizeMailLimitRepository, private readonly featureLimitRepository: SequelizeFeatureLimitsRepository, - private readonly apnService: ApnService, ) {} findByEmail(email: User['email']): Promise {