diff --git a/.env.example b/.env.example index 02b2c2b..c1d5d73 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,9 @@ REFRESH_TOKEN_EXPIRY= # Bcrypt Config BCRYPT_SALT_ROUNDS= +# HMAC Config +HMAC_SECRET= + # Google OAuth Config GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= diff --git a/TODO.md b/TODO.md index 20a0323..f8bc24d 100644 --- a/TODO.md +++ b/TODO.md @@ -29,6 +29,7 @@ - [x] OTP expiration must be less than 15m. Decide on the exact duration (e.g., 5m) - [ ] Add middleware to check if user is deleted - [ ] Centralize error messages +- [x] Integrate with BullMQ for background job processing ## Improvements diff --git a/deploy.sh b/deploy.sh index 10a81ed..64663f7 100755 --- a/deploy.sh +++ b/deploy.sh @@ -15,13 +15,21 @@ npx prisma generate --schema=src/database/prisma/schema.prisma echo "🏗 Building the app..." npm run build -echo "🚀 Restarting app with PM2..." +echo "🚀 Restarting apps with PM2..." if pm2 describe taskora-api > /dev/null 2>&1; then - echo "📍 Restarting existing process..." + echo "📍 Restarting taskora-api..." pm2 restart taskora-api else - echo "🆕 Starting new process with ecosystem config..." - pm2 start ecosystem.config.js + echo "🆕 Starting taskora-api from ecosystem config..." + pm2 start ecosystem.config.js --only taskora-api +fi + +if pm2 describe taskora-email-worker > /dev/null 2>&1; then + echo "📍 Restarting taskora-email-worker..." + pm2 restart taskora-email-worker +else + echo "🆕 Starting taskora-email-worker from ecosystem config..." + pm2 start ecosystem.config.js --only taskora-email-worker fi echo "💾 Saving PM2 process list..." diff --git a/ecosystem.config.js b/ecosystem.config.js index 92bd1a8..8176c69 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -2,7 +2,7 @@ module.exports = { apps: [ { name: 'taskora-api', - script: './dist/index.js', + script: 'dist/index.js', instances: 1, exec_mode: 'fork', node_args: '--expose-gc --max-old-space-size=1024', @@ -15,5 +15,16 @@ module.exports = { kill_timeout: 5000, watch: false, }, + { + name: 'taskora-email-worker', + script: 'dist/workers/email.worker.js', + instances: 1, + autorestart: true, + watch: false, + env: { + NODE_ENV: 'production', + FRONTEND_URL: 'https://taskora.live', + }, + }, ], }; diff --git a/package.json b/package.json index 58af820..149e145 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "post:build": "cpy src/templates/**/*.html dist/templates", "dev": "concurrently \"tsc-watch --onSuccess 'node ./dist/index.js'\" \"rm -rf dist\" \"npm run build\"", "start": "NODE_ENV=production node dist/index.js", + "start:worker": "node dist/workers/email.worker.js", "format": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"", "lint": "eslint \"src/**/*.ts\" --fix", diff --git a/src/config/general.env.ts b/src/config/general.env.ts index 583560e..a073468 100644 --- a/src/config/general.env.ts +++ b/src/config/general.env.ts @@ -14,3 +14,4 @@ export const mailPort = env('MAIL_PORT'); export const mailAuthUser = env('MAIL_AUTH_USER'); export const mailAuthPassword = env('MAIL_AUTH_PASSWORD'); export const correctAnswer = env('CORRECT_ANSWER'); +export const hmacSecret = env('HMAC_SECRET'); diff --git a/src/jobs/email.job.ts b/src/jobs/email.job.ts new file mode 100644 index 0000000..8c7095c --- /dev/null +++ b/src/jobs/email.job.ts @@ -0,0 +1,76 @@ +import { JobsOptions } from 'bullmq'; +import { emailQueue } from '../queues'; +import { HashingService } from '../services'; +import { SendEmailVerificationJob, SendForgetPasswordJob } from '../types'; +import { logger, MAGIC_NUMBERS, WORKERS } from '../utils'; + +export class EmailJob { + static async addVerificationEmailJob( + verificationJobDetails: SendEmailVerificationJob, + ) { + try { + const hashedEmail = this.hashEmail(verificationJobDetails.email); + const key = `verify-${hashedEmail}`; + const jobOptions = this.createJobOptions(key); + await emailQueue.add( + WORKERS.SEND_VERIFICATION_EMAIL, + { + email: verificationJobDetails.email, + name: verificationJobDetails.name, + token: verificationJobDetails.token, + }, + jobOptions, + ); + + logger.info( + `Verification email job scheduled for ${verificationJobDetails.email} with key ${key}`, + ); + } catch (error) { + logger.error(`Failed to add verification email job: ${error}`); + throw error; + } + } + + static async addForgetPasswordEmailJob( + forgetPasswordDetails: SendForgetPasswordJob, + ) { + try { + const hashedEmail = this.hashEmail(forgetPasswordDetails.email); + const key = `forget-${hashedEmail}`; + const jobOptions = this.createJobOptions(key); + await emailQueue.add( + WORKERS.SEND_FORGET_PASSWORD_EMAIL, + { + email: forgetPasswordDetails.email, + name: forgetPasswordDetails.name, + otp: forgetPasswordDetails.otp, + }, + jobOptions, + ); + + logger.info( + `Forget password email job scheduled for ${forgetPasswordDetails.email} with key ${key}`, + ); + } catch (error) { + logger.error(`Failed to add forget password email job: ${error}`); + throw error; + } + } + + private static createJobOptions(jobId: string): JobsOptions { + return { + jobId, + attempts: MAGIC_NUMBERS.MAX_NUMBER_OF_RETRIES, + backoff: { + type: 'exponential', + delay: MAGIC_NUMBERS.FIVE_SECONDS_IN_MILLISECONDS, + }, + removeOnComplete: MAGIC_NUMBERS.MAX_COUNT_FOR_REMOVE_ON_COMPLETE, + removeOnFail: MAGIC_NUMBERS.MAX_COUNT_FOR_REMOVE_ON_FAILURE, + }; + } + + private static hashEmail(email: string): string { + return HashingService.generateHashWithHmac(email); + } +} diff --git a/src/jobs/index.ts b/src/jobs/index.ts new file mode 100644 index 0000000..b80fcd9 --- /dev/null +++ b/src/jobs/index.ts @@ -0,0 +1 @@ +export * from './email.job'; diff --git a/src/queues/email.queue.ts b/src/queues/email.queue.ts new file mode 100644 index 0000000..2dc6147 --- /dev/null +++ b/src/queues/email.queue.ts @@ -0,0 +1,8 @@ +import { Queue } from 'bullmq'; +import { redisHost, redisPort } from '../config'; +import { EmailJobData } from '../types'; +import { QUEUES } from '../utils'; + +export const emailQueue = new Queue(QUEUES.EMAIL_QUEUE, { + connection: { host: redisHost, port: Number(redisPort) }, +}); diff --git a/src/queues/index.ts b/src/queues/index.ts index 3159904..a186cb8 100644 --- a/src/queues/index.ts +++ b/src/queues/index.ts @@ -1 +1,2 @@ export * from './competition.queue'; +export * from './email.queue'; diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 0141386..7fa7eba 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -1,6 +1,5 @@ import { Provider } from '@prisma/client'; import { - emailService, HashingService, JwtService, otpService, @@ -9,6 +8,7 @@ import { userService, } from '.'; import { IAuth, IResetPassword, IUser, IVerifyOtp } from '../interfaces'; +import { EmailJob } from '../jobs'; import { IUserInfo } from '../types'; import { ApiError, @@ -82,11 +82,11 @@ export class AuthService extends BaseAuthService { MAGIC_NUMBERS.ONE_DAY_IN_SECONDS, ); - emailService.sendVerificationEmail( - user.email, - user.name, - verificationToken, - ); + await EmailJob.addVerificationEmailJob({ + email: user.email, + name: user.name, + token: verificationToken, + }); } async login(userLoginInfo: IAuth) { @@ -272,7 +272,11 @@ export class AuthService extends BaseAuthService { ), ]); - emailService.sendForgetPasswordEmail(user.email, user.name, otp); + await EmailJob.addForgetPasswordEmailJob({ + email: user.email, + name: user.name, + otp, + }); } async verifyOTP(otpInfo: IVerifyOtp) { diff --git a/src/services/email.service.ts b/src/services/email.service.ts index 641eb9f..278ab9b 100644 --- a/src/services/email.service.ts +++ b/src/services/email.service.ts @@ -4,6 +4,7 @@ import { getOTPTemplate, getVerifyEmailTemplate, getWinnersTemplate, + logger, } from '../utils'; import { smtpMailProvider } from './providers'; @@ -11,43 +12,68 @@ export class EmailService { constructor(private readonly mailProvider: IMailProvider) {} async sendVerificationEmail(email: string, name: string, token: string) { - const verifyEmailTemplate = getVerifyEmailTemplate(); - const verifyEmailUrl = `${frontendUrl}/verify-email?token=${token}`; - - const html = verifyEmailTemplate - .replace(/{{verifyEmailUrl}}/g, verifyEmailUrl) - .replace(/{{name}}/g, name); - - await this.mailProvider.sendEmail({ - to: email, - subject: 'Verify your email', - text: 'Verify your email', - html, - }); + try { + const verifyEmailTemplate = getVerifyEmailTemplate(); + const verifyEmailUrl = `${frontendUrl}/verify-email?token=${token}`; + + const html = verifyEmailTemplate + .replace(/{{verifyEmailUrl}}/g, verifyEmailUrl) + .replace(/{{name}}/g, name); + + await this.mailProvider.sendEmail({ + to: email, + subject: 'Verify your email', + text: 'Verify your email', + html, + }); + + logger.info(`Verification email sent to ${email}`); + } catch (error) { + logger.error(`Failed to send verification email to ${email}: ${error}`); + throw error; + } } async sendForgetPasswordEmail(email: string, name: string, otp: string) { - const html = getOTPTemplate() - .replace(/{{otp}}/g, otp) - .replace(/{{name}}/g, name); - - await this.mailProvider.sendEmail({ - to: email, - subject: 'Reset your password', - text: 'Reset your password', - html, - }); + try { + const html = getOTPTemplate() + .replace(/{{otp}}/g, otp) + .replace(/{{name}}/g, name); + + await this.mailProvider.sendEmail({ + to: email, + subject: 'Reset your password', + text: 'Reset your password', + html, + }); + + logger.info(`Forget password email sent to ${email}`); + } catch (error) { + logger.error( + `Failed to send forget password email to ${email}: ${error}`, + ); + throw error; + } } async notifyWinnerViaEmail(email: string, name: string) { - const html = getWinnersTemplate().replace(/{{name}}/g, name); - - await this.mailProvider.sendEmail({ - to: email, - subject: 'Congratulations! You are a winner', - text: 'Congratulations! You are a winner', - html, - }); + try { + const html = getWinnersTemplate().replace(/{{name}}/g, name); + + await this.mailProvider.sendEmail({ + to: email, + subject: 'Congratulations! You are a winner', + text: 'Congratulations! You are a winner', + html, + }); + + logger.info(`Winner notification email sent to ${email}`); + } catch (error) { + logger.error( + `Failed to send winner notification email to ${email}: ${error}`, + ); + throw error; + } } } diff --git a/src/services/hashing.service.ts b/src/services/hashing.service.ts index aac3aad..84f03d9 100644 --- a/src/services/hashing.service.ts +++ b/src/services/hashing.service.ts @@ -1,5 +1,6 @@ import { compare, hash } from 'bcryptjs'; -import { bcryptSaltRounds } from '../config'; +import crypto from 'crypto'; +import { bcryptSaltRounds, hmacSecret } from '../config'; import { ApiError, BAD_REQUEST } from '../utils'; export class HashingService { @@ -9,10 +10,15 @@ export class HashingService { } return await hash(text, Number(bcryptSaltRounds)); } + static compare(text: string, hashedText: string): Promise { if (!text || !hashedText) { throw new ApiError('Missing information to compare', BAD_REQUEST); } return compare(text, hashedText); } + + static generateHashWithHmac(text: string) { + return crypto.createHmac('sha256', hmacSecret).update(text).digest('hex'); + } } diff --git a/src/types/index.ts b/src/types/index.ts index c7bdc74..80131bb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,5 @@ export * from './jwt'; +export * from './queue'; export * from './status'; export * from './statusCodes'; export * from './user'; diff --git a/src/types/queue.ts b/src/types/queue.ts new file mode 100644 index 0000000..3d8d8a9 --- /dev/null +++ b/src/types/queue.ts @@ -0,0 +1,17 @@ +import { WORKERS } from '../utils'; + +export type SendEmailVerificationJob = { + email: string; + name: string; + token: string; +}; + +export type SendForgetPasswordJob = { + email: string; + name: string; + otp: string; +}; + +export type EmailJobData = SendEmailVerificationJob | SendForgetPasswordJob; + +export type EmailJobName = (typeof WORKERS)[keyof typeof WORKERS]; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index b9b405c..b800667 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -34,18 +34,19 @@ export const MAGIC_NUMBERS = { ONE_MINUTE_IN_MILLISECONDS: 60 * 1000, ONE_DAY_IN_MILLISECONDS: 24 * 60 * 60 * 1000, ONE_WEEK_IN_MILLISECONDS: 7 * 24 * 60 * 60 * 1000, - FIFTEEN_MINUTES_IN_MILLISECONDS: 15 * 60 * 1000, + FIVE_SECONDS_IN_MILLISECONDS: 5 * 1000, FIVE_MINUTES_IN_MILLISECONDS: 5 * 60 * 1000, + ONE_HOUR_IN_SECONDS: 60 * 60, ONE_DAY_IN_SECONDS: 24 * 60 * 60, FIVE_MINUTES_IN_SECONDS: 5 * 60, - FIFTEEN_MINUTES_IN_SECONDS: 15 * 60, MAX_FILE_SIZE: 5 * 1024 * 1024, - OTP_LENGTH: 6, - NUMBER_OF_BYTES: 32, + MAX_NUMBER_OF_RETRIES: 5, + MAX_NUMBER_OF_CONCURRENT_JOBS: 5, + MAX_COUNT_FOR_REMOVE_ON_COMPLETE: 100, + MAX_COUNT_FOR_REMOVE_ON_FAILURE: 100, MAX_NUMBER_OF_ALLOWED_REQUESTS: { ONE: 1, THREE: 3, - FIVE: 5, TEN: 10, }, }; @@ -63,3 +64,12 @@ export const DEFAULT_VALUES = { 'Add your top three priorities for the week and start managing your time effectively', }, }; + +export const QUEUES = { + EMAIL_QUEUE: 'EMAIL_QUEUE', +}; + +export const WORKERS = { + SEND_VERIFICATION_EMAIL: 'SEND_VERIFICATION_EMAIL', + SEND_FORGET_PASSWORD_EMAIL: 'SEND_FORGET_PASSWORD_EMAIL', +} as const; diff --git a/src/workers/email.worker.ts b/src/workers/email.worker.ts new file mode 100644 index 0000000..39efab3 --- /dev/null +++ b/src/workers/email.worker.ts @@ -0,0 +1,84 @@ +import { Job, Worker } from 'bullmq'; +import 'dotenv/config'; +import { redisHost, redisPort } from '../config'; +import { emailService } from '../services'; +import { + EmailJobData, + EmailJobName, + SendEmailVerificationJob, + SendForgetPasswordJob, +} from '../types'; +import { logger, MAGIC_NUMBERS, QUEUES, WORKERS } from '../utils'; + +export const emailWorker = new Worker( + QUEUES.EMAIL_QUEUE, + async (job: Job) => { + switch (job.name) { + case WORKERS.SEND_VERIFICATION_EMAIL: + { + const data = job.data as SendEmailVerificationJob; + await emailService.sendVerificationEmail( + data.email, + data.name, + data.token, + ); + } + break; + + case WORKERS.SEND_FORGET_PASSWORD_EMAIL: + { + const data = job.data as SendForgetPasswordJob; + await emailService.sendForgetPasswordEmail( + data.email, + data.name, + data.otp, + ); + } + break; + + default: + throw new Error( + `Unknown job name: ${job.name}. Expected: ${Object.values(WORKERS).join(', ')}`, + ); + } + }, + { + connection: { host: redisHost, port: Number(redisPort) }, + concurrency: MAGIC_NUMBERS.MAX_NUMBER_OF_CONCURRENT_JOBS, + }, +); + +emailWorker.on('completed', (job) => { + logger.info(`Job ${job.id} has been completed ✅`); +}); + +emailWorker.on('failed', (job: Job | undefined, err) => { + const message = err && err.message ? err.message : err; + if (job) { + logger.error(`Job ${job.id} has failed ❌ with error: ${message}`); + } else { + logger.error(`Job is undefined ❌ with error: ${message}`); + } +}); + +emailWorker.on('error', (err) => { + logger.error(`Email worker encountered an error: ${err?.message ?? err}`); +}); + +const shutdown = async (signal: string) => { + logger.info(`Received ${signal}. Shutting down email worker gracefully...`); + + try { + await emailWorker.pause(true); + logger.info('Worker paused. Waiting for active jobs to finish...'); + + await emailWorker.close(); + logger.info('Email worker shutdown complete ✅'); + } catch (err) { + logger.error(`Error during graceful shutdown: ${err}`); + process.exit(1); + } +}; + +process.on('SIGINT', () => void shutdown('SIGINT')); +process.on('SIGTERM', () => void shutdown('SIGTERM')); diff --git a/src/workers/index.ts b/src/workers/index.ts index c665ac8..61c1551 100644 --- a/src/workers/index.ts +++ b/src/workers/index.ts @@ -1 +1,2 @@ export * from './competition.worker'; +export * from './email.worker';