Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7e2b044
feat: add queue types for email jobs
MuhammedMagdyy Sep 1, 2025
96f927d
refactor: update MAGIC_NUMBERS and add QUEUES and WORKERS constants
MuhammedMagdyy Sep 1, 2025
210830a
feat: add email queue implementation
MuhammedMagdyy Sep 1, 2025
a8d6784
feat: implement email worker for handling email jobs
MuhammedMagdyy Sep 1, 2025
6c28f70
feat: handling email verification and password reset jobs
MuhammedMagdyy Sep 1, 2025
c5ec0c1
refactor: enhance error handling and logging in email service methods
MuhammedMagdyy Sep 1, 2025
c2cfb45
refactor: replace email service calls with email job implementations …
MuhammedMagdyy Sep 1, 2025
aea1f51
chore: add start:worker script to run email worker
MuhammedMagdyy Sep 1, 2025
b135d4f
refactor: improve deployment script by adding email worker
MuhammedMagdyy Sep 1, 2025
31cb9e0
chore: update ecosystem config to include email worker with proper sc…
MuhammedMagdyy Sep 1, 2025
466d738
docs: mark integration with BullMQ for background job processing as c…
MuhammedMagdyy Sep 1, 2025
5cbff71
refactor: replace magic numbers with constants
MuhammedMagdyy Sep 1, 2025
f2c0f27
refactor: update EmailJobName type to use WORKERS constant
MuhammedMagdyy Sep 1, 2025
d173f48
refactor: enhance error logging for email sending and job handling
MuhammedMagdyy Sep 1, 2025
05f6e70
refactor: implement email hashing for job keys and enhance logging
MuhammedMagdyy Sep 1, 2025
e476503
refactor: improve graceful shutdown process for email worker
MuhammedMagdyy Sep 1, 2025
309dc70
refactor: add HMAC secret to environment configuration and update has…
MuhammedMagdyy Sep 1, 2025
a436c2b
refactor: change job backoff strategy to exponential for better retry…
MuhammedMagdyy Sep 1, 2025
5c3be69
refactor: replace logger warning with error throw for unknown job names
MuhammedMagdyy Sep 1, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
1 change: 1 addition & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 12 additions & 4 deletions deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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..."
Expand Down
13 changes: 12 additions & 1 deletion ecosystem.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
},
},
],
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/config/general.env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
76 changes: 76 additions & 0 deletions src/jobs/email.job.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Comment thread
MuhammedMagdyy marked this conversation as resolved.

private static hashEmail(email: string): string {
return HashingService.generateHashWithHmac(email);
}
}
1 change: 1 addition & 0 deletions src/jobs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './email.job';
8 changes: 8 additions & 0 deletions src/queues/email.queue.ts
Original file line number Diff line number Diff line change
@@ -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<EmailJobData>(QUEUES.EMAIL_QUEUE, {
connection: { host: redisHost, port: Number(redisPort) },
Comment thread
MuhammedMagdyy marked this conversation as resolved.
});
1 change: 1 addition & 0 deletions src/queues/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './competition.queue';
export * from './email.queue';
18 changes: 11 additions & 7 deletions src/services/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Provider } from '@prisma/client';
import {
emailService,
HashingService,
JwtService,
otpService,
Expand All @@ -9,6 +8,7 @@ import {
userService,
} from '.';
import { IAuth, IResetPassword, IUser, IVerifyOtp } from '../interfaces';
import { EmailJob } from '../jobs';
import { IUserInfo } from '../types';
import {
ApiError,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
88 changes: 57 additions & 31 deletions src/services/email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,50 +4,76 @@ import {
getOTPTemplate,
getVerifyEmailTemplate,
getWinnersTemplate,
logger,
} from '../utils';
import { smtpMailProvider } from './providers';

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;
}
}
}

Expand Down
8 changes: 7 additions & 1 deletion src/services/hashing.service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -9,10 +10,15 @@ export class HashingService {
}
return await hash(text, Number(bcryptSaltRounds));
}

static compare(text: string, hashedText: string): Promise<boolean> {
if (!text || !hashedText) {
throw new ApiError('Missing information to compare', BAD_REQUEST);
}
return compare(text, hashedText);
}

static generateHashWithHmac(text: string) {
Comment thread
MuhammedMagdyy marked this conversation as resolved.
return crypto.createHmac('sha256', hmacSecret).update(text).digest('hex');
}
}
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './jwt';
export * from './queue';
export * from './status';
export * from './statusCodes';
export * from './user';
17 changes: 17 additions & 0 deletions src/types/queue.ts
Original file line number Diff line number Diff line change
@@ -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];
Loading