Skip to content

Commit

Permalink
feat: Add user notification tokens model and service method
Browse files Browse the repository at this point in the history
  • Loading branch information
jzunigax2 committed Jun 28, 2024
1 parent 42355bf commit 2ecafe1
Show file tree
Hide file tree
Showing 5 changed files with 207 additions and 16 deletions.
9 changes: 8 additions & 1 deletion src/app/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import initLimit, { LimitModel } from './limit';
import initTier, { TierModel } from './tier';
import initPaidPlan, { PaidPlansModel } from './paidPlans';
import initTierLimit, { TierLimitsModel } from './tierLimit';
import initUserNotificationTokens, { UserNotificationTokenModel } from './userNotificationTokens';

export type ModelType =
| AppSumoModel
Expand Down Expand Up @@ -53,7 +54,8 @@ export type ModelType =
| PaidPlansModel
| TierLimitsModel
| LimitModel
| TierModel;
| TierModel
| UserNotificationTokenModel;

export default (database: Sequelize) => {
const AppSumo = initAppSumo(database);
Expand Down Expand Up @@ -82,6 +84,7 @@ export default (database: Sequelize) => {
const Tier = initTier(database);
const PaidPlans = initPaidPlan(database);
const TierLimit = initTierLimit(database);
const UserNotificationToken = initUserNotificationTokens(database);

AppSumo.belongsTo(User);

Expand Down Expand Up @@ -132,6 +135,7 @@ export default (database: Sequelize) => {
User.hasMany(PrivateSharingFolder, { foreignKey: 'shared_with', sourceKey: 'uuid' });
User.hasMany(Sharings, { foreignKey: 'owner_id', sourceKey: 'uuid' });
User.hasMany(Sharings, { foreignKey: 'shared_with', sourceKey: 'uuid' });
User.hasMany(UserNotificationToken, { foreignKey: 'userId', sourceKey: 'uuid' });

UserReferral.belongsTo(User, { foreignKey: 'user_id' });
UserReferral.belongsTo(Referral, { foreignKey: 'referral_id' });
Expand Down Expand Up @@ -170,6 +174,8 @@ export default (database: Sequelize) => {
as: 'tiers',
});

UserNotificationToken.belongsTo(User, { foreignKey: 'userId', targetKey: 'uuid' });

return {
[AppSumo.name]: AppSumo,
[Backup.name]: Backup,
Expand Down Expand Up @@ -197,5 +203,6 @@ export default (database: Sequelize) => {
[Limit.name]: Limit,
[PaidPlans.name]: PaidPlans,
[TierLimit.name]: TierLimit,
[UserNotificationToken.name]: UserNotificationToken,
};
};
60 changes: 60 additions & 0 deletions src/app/models/userNotificationTokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { DataTypes, ModelDefined, Sequelize } from 'sequelize';

export interface UserNotificationTokenAttributes {
id: string;
userId: string;
token: string;
type: 'macos' | 'android' | 'ios';
createdAt: Date;
updatedAt: Date;
}

export type UserNotificationTokenModel = ModelDefined<UserNotificationTokenAttributes, UserNotificationTokenAttributes>;

export default (database: Sequelize): UserNotificationTokenModel => {
const UserNotificationToken: UserNotificationTokenModel = database.define(
'user_notification_tokens',
{
id: {
type: DataTypes.UUID,
primaryKey: true,
defaultValue: DataTypes.UUIDV4,
},
userId: {
type: DataTypes.STRING(36),
allowNull: false,
references: {
model: 'users',
key: 'uuid',
},
},
token: {
type: DataTypes.STRING,
allowNull: false,
},
type: {
type: DataTypes.ENUM('macos', 'android', 'ios'),
allowNull: false,
},
createdAt: {
field: 'created_at',
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
updatedAt: {
field: 'updated_at',
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
},
{
tableName: 'user_notification_tokens',
timestamps: true,
underscored: true,
},
);

return UserNotificationToken;
};
12 changes: 5 additions & 7 deletions src/app/routes/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ import * as ReCaptchaV3 from '../../lib/recaptcha';
import * as AnalyticsService from '../../lib/analytics/AnalyticsService';
import { AuthorizedUser } from './types';
import { default as Notifications } from '../../config/initializers/notifications';
import { default as Apn } from '../../config/initializers/apn';

const Logger = logger.getInstance();

export default (router: Router, service: any, App: any): Router => {
service.Analytics = AnalyticsService;
service.ReCaptcha = ReCaptchaV3;
service.Notifications = Notifications.getInstance();
service.Apn = Apn.getInstance();

AuthRoutes(router, service, App.config);
ActivationRoutes(router, service);
Expand Down Expand Up @@ -107,7 +109,7 @@ export default (router: Router, service: any, App: any): Router => {
if (!userData.root_folder_id) {
throw createHttpError(500, 'Account can not be initialized');
}

const user = {
email: userData.email,
bucket: userData.bucket,
Expand All @@ -121,13 +123,9 @@ export default (router: Router, service: any, App: any): Router => {
res.status(200).send({ user });
} catch (err) {
Logger.error(
`[AUTH/INITIALIZE] ERROR: ${
(err as Error).message
}, BODY ${
JSON.stringify(req.body)
}, STACK: ${
`[AUTH/INITIALIZE] ERROR: ${(err as Error).message}, BODY ${JSON.stringify(req.body)}, STACK: ${
(err as Error).stack
}`
}`,
);

return res.status(500).send({ error: 'Internal Server Error' });
Expand Down
29 changes: 21 additions & 8 deletions src/app/services/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -307,12 +307,15 @@ module.exports = (Model, App) => {

await user.destroy();

await Model.folder.update({ deleted: true, removed: true }, {
where: {
user_id: user.id,
parent_id: null,
}
});
await Model.folder.update(
{ deleted: true, removed: true },
{
where: {
user_id: user.id,
parent_id: null,
},
},
);

logger.info('User %s confirmed deactivation', userEmail);
} catch (err) {
Expand Down Expand Up @@ -357,7 +360,7 @@ module.exports = (Model, App) => {
password: newPassword,
mnemonic,
hKey: newSalt,
lastPasswordChangedAt: new Date()
lastPasswordChangedAt: new Date(),
},
{
where: { username: { [Op.eq]: user.email } },
Expand Down Expand Up @@ -522,7 +525,7 @@ module.exports = (Model, App) => {
attributes: [[fn('sum', col('size')), 'total']],
raw: true,
});

driveUsage = parseInt(usage[0].total);

await Redis.setUsage(user.uuid, driveUsage);
Expand Down Expand Up @@ -797,6 +800,15 @@ module.exports = (Model, App) => {
}
};

const getUserNotificationTokens = async (user, type = null) => {
let whereClause = { userId: user.uuid };

if (type !== null) {
whereClause.type = type;
}
return Model.UserNotificationToken.findAll({ where: whereClause });
};

return {
Name: 'User',
FindOrCreate,
Expand Down Expand Up @@ -833,5 +845,6 @@ module.exports = (Model, App) => {
sendEmailVerification,
verifyEmail,
updateTier,
getUserNotificationTokens,
};
};
113 changes: 113 additions & 0 deletions src/config/initializers/apn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import * as http2 from 'http2';
import jwt, { JwtHeader } from 'jsonwebtoken';
import Logger from '../../lib/logger';

export default class Apn {
private static instance: Apn;
private client: http2.ClientHttp2Session;
private readonly maxReconnectAttempts = 3;
private reconnectAttempts = 0;
private reconnectDelay = 1000;
private readonly bundleId = process.env.APN_BUNDLE_ID;

private jwt: string | null = null;
private jwtGeneratedAt = 0;

constructor() {
this.client = this.connectToAPN();
}

static getInstance(): Apn {
if (!Apn.instance) {
Apn.instance = new Apn();
}
return Apn.instance;
}

private connectToAPN(): http2.ClientHttp2Session {
const apnSecret = process.env.APN_SECRET;
const apnKeyId = process.env.APN_KEY_ID;
const apnTeamId = process.env.APN_TEAM_ID;

if (!apnSecret || !apnKeyId || !apnTeamId) {
Logger.getInstance().warn('APN env variables must be defined');
}

const client = http2.connect(process.env.APN_URL as string, {});

client.on('error', (err) => {
Logger.getInstance().error('APN connection error', err);
});
client.on('close', () => {
Logger.getInstance().warn('APN connection was closed');
this.handleReconnect();
});
client.on('connect', () => {
Logger.getInstance().info('Connected to APN');
});

return client;
}

private generateJwt(): string {
if (this.jwt && Date.now() - this.jwtGeneratedAt < 3600) {
return this.jwt;
}

this.jwt = jwt.sign(
{
iss: process.env.APN_TEAM_ID,
iat: Math.floor(Date.now() / 1000),
},
process.env.APN_SECRET as string,
{
algorithm: 'ES256',
header: {
alg: 'ES256',
kid: process.env.APN_KEY_ID,
} as JwtHeader,
},
);

this.jwtGeneratedAt = Date.now();

return this.jwt;
}

private handleReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
setTimeout(() => {
Logger.getInstance().info(`Attempting to reconnect to APN (#${this.reconnectAttempts + 1})`);
this.connectToAPN();
this.reconnectAttempts++;
}, this.reconnectDelay * Math.pow(2, this.reconnectAttempts));
} else {
Logger.getInstance().error('Maximum APN reconnection attempts reached');
}
}

public sendNotification(payload: Record<string, any>, topic?: string): void {
const headers = {
'apns-topic': topic ?? `${this.bundleId}.pushkit.fileprovider`,
authorization: `bearer ${this.generateJwt()}`,
};

const options = {
':method': 'POST',
':path': `/3/device/${payload.deviceToken}`,
':scheme': 'https',
':authority': 'api.push.apple.com',
'content-type': 'application/json',
};

const req = this.client.request({ ...options, ...headers });

req.setEncoding('utf8');
req.write(JSON.stringify(payload));
req.end();

req.on('error', (err) => {
Logger.getInstance().error('APN request error', err);
});
}
}

0 comments on commit 2ecafe1

Please sign in to comment.