Skip to content

Commit

Permalink
feat: added apn notifications to events
Browse files Browse the repository at this point in the history
  • Loading branch information
apsantiso committed Sep 11, 2024
1 parent 0240d28 commit a459685
Show file tree
Hide file tree
Showing 7 changed files with 124 additions and 50 deletions.
2 changes: 1 addition & 1 deletion src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
1 change: 0 additions & 1 deletion src/externals/apn/apn.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
78 changes: 36 additions & 42 deletions src/externals/apn/apn.service.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
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';
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;
Expand All @@ -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;
Expand All @@ -82,22 +70,27 @@ 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();
}

const headers: http2.OutgoingHttpHeaders = {
[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 });
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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');
}
}

Expand All @@ -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();
}
});
Expand Down
17 changes: 16 additions & 1 deletion src/externals/notifications/notifications.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,6 +36,7 @@ import { StorageNotificationService } from './storage.notifications.service';
ShareLinkListener,
AuthListener,
NewsletterService,
SequelizeUserRepository,
],
exports: [NotificationService, StorageNotificationService],
})
Expand Down
51 changes: 49 additions & 2 deletions src/externals/notifications/storage.notifications.service.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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(
Expand All @@ -32,6 +38,7 @@ export class StorageNotificationService {
);

this.notificationService.add(event);
this.getTokensAndSendNotification(user.uuid);
}

fileUpdated({ payload, user, clientId }: EventArguments) {
Expand All @@ -45,6 +52,7 @@ export class StorageNotificationService {
);

this.notificationService.add(event);
this.getTokensAndSendNotification(user.uuid);
}

folderCreated({ payload, user, clientId }: EventArguments) {
Expand All @@ -58,6 +66,7 @@ export class StorageNotificationService {
);

this.notificationService.add(event);
this.getTokensAndSendNotification(user.uuid);
}

folderUpdated({ payload, user, clientId }: EventArguments) {
Expand All @@ -71,6 +80,7 @@ export class StorageNotificationService {
);

this.notificationService.add(event);
this.getTokensAndSendNotification(user.uuid);
}

itemsTrashed({ payload, user, clientId }: EventArguments) {
Expand All @@ -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,
);
}
}
}
23 changes: 22 additions & 1 deletion src/modules/user/user.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,14 @@ export interface UserRepository {
getMeetClosedBetaUsers(): Promise<string[]>;
setRoomToBetaUser(room: string, user: User): Promise<void>;
getBetaUserFromRoom(room: string): Promise<User | null>;
getNotificationTokens(userId: string): Promise<UserNotificationTokens[]>;
getNotificationTokens(
userId: string,
where?: Partial<Omit<UserNotificationTokens, 'userId'>>,
): Promise<UserNotificationTokens[]>;
deleteUserNotificationTokens(
userUuid: UserAttributes['uuid'],
tokens: string[],
): Promise<void>;
getNotificationTokenCount(userId: string): Promise<number>;
}

Expand Down Expand Up @@ -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,
Expand Down
2 changes: 0 additions & 2 deletions src/modules/user/user.usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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<User | null> {
Expand Down

0 comments on commit a459685

Please sign in to comment.