Merge pull request #1 from techzaaa/abumahid

🔧 chore(account): update account module structure and email queue pro…
This commit is contained in:
2026-04-03 00:55:43 +06:00
committed by GitHub
18 changed files with 285 additions and 98 deletions
+18
View File
@@ -23,5 +23,23 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
redis:
image: redis:7-alpine
container_name: server_redis
restart: always
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
volumes: volumes:
postgres_data: postgres_data:
redis_data:
+2
View File
@@ -16,11 +16,13 @@
"@prisma/adapter-pg": "^7.5.0", "@prisma/adapter-pg": "^7.5.0",
"@prisma/client": "^7.5.0", "@prisma/client": "^7.5.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bullmq": "^5.72.1",
"cloudinary": "^2.7.0", "cloudinary": "^2.7.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"express": "^5.1.0", "express": "^5.1.0",
"ioredis": "^5.10.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"multer": "^2.0.2", "multer": "^2.0.2",
"nodemailer": "^7.0.9", "nodemailer": "^7.0.9",
@@ -21,8 +21,12 @@ CREATE TABLE "Account" (
CREATE TABLE "Profile" ( CREATE TABLE "Profile" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"accountId" TEXT NOT NULL, "accountId" TEXT NOT NULL,
"fullName" TEXT NOT NULL, "shopName" TEXT NOT NULL,
"profilePhoto" TEXT, "shopLogo" TEXT,
"contactNumber" TEXT,
"shopAddress" TEXT,
"shopMapLocation" TEXT,
"shopCategory" TEXT,
CONSTRAINT "Profile_pkey" PRIMARY KEY ("id") CONSTRAINT "Profile_pkey" PRIMARY KEY ("id")
); );
+9 -9
View File
@@ -4,16 +4,16 @@ enum ROLE {
} }
model Account { model Account {
id String @id @default(uuid()) id String @id @default(uuid())
email String @unique email String @unique
password String password String
role ROLE @default(USER) role ROLE @default(USER)
lastOtp String? lastOtp String?
lastOtpSendingTime DateTime? lastOtpSendingTime DateTime?
isDeleted Boolean @default(false) isDeleted Boolean @default(false)
isAccountVerified Boolean @default(false) isAccountVerified Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) updatedAt DateTime @default(now())
profile Profile? profile Profile?
} }
+6 -2
View File
@@ -3,6 +3,10 @@ model Profile {
accountId String @unique accountId String @unique
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
fullName String shopName String
profilePhoto String? shopLogo String?
contactNumber String?
shopAddress String?
shopMapLocation String?
shopCategory String?
} }
@@ -18,7 +18,7 @@ const verify_account_using_otp = catchAsync(async (req, res) => {
manageResponse(res, { manageResponse(res, {
statusCode: 200, statusCode: 200,
success: true, success: true,
message: "Otp verification successfull", message: "Otp verification successful",
data: result, data: result,
}); });
}); });
@@ -28,7 +28,7 @@ const verify_account_using_link = catchAsync(async (req, res) => {
manageResponse(res, { manageResponse(res, {
statusCode: 200, statusCode: 200,
success: true, success: true,
message: "Account verification successfull", message: "Account verification successful",
data: result, data: result,
}); });
}); });
@@ -83,9 +83,9 @@ const resend_otp_and_verification_link = catchAsync(async (req, res) => {
}); });
}); });
const forget_password_genereate_reset_token = catchAsync(async (req, res) => { const forget_password_generate_reset_token = catchAsync(async (req, res) => {
const result = const result =
await account_services.forget_password_genereate_reset_token_from_db(req); await account_services.forget_password_generate_reset_token_from_db(req);
manageResponse(res, { manageResponse(res, {
statusCode: 200, statusCode: 200,
success: true, success: true,
@@ -111,6 +111,6 @@ export const account_controller = {
verify_account_using_otp, verify_account_using_otp,
resend_otp_and_verification_link, resend_otp_and_verification_link,
verify_account_using_link, verify_account_using_link,
forget_password_genereate_reset_token, forget_password_generate_reset_token,
reset_password_using_token reset_password_using_token
}; };
+1 -1
View File
@@ -45,7 +45,7 @@ accountRouter.put(
accountRouter.put( accountRouter.put(
"/forget-password", "/forget-password",
RequestValidator(account_validation.resend_otp), RequestValidator(account_validation.resend_otp),
account_controller.forget_password_genereate_reset_token, account_controller.forget_password_generate_reset_token,
); );
accountRouter.put( accountRouter.put(
"/reset-password", "/reset-password",
+22 -21
View File
@@ -2,14 +2,22 @@ import bcrypt from "bcrypt";
import { Request } from "express"; import { Request } from "express";
import { configs } from "../../configs"; import { configs } from "../../configs";
import { prisma } from "../../lib/prisma"; import { prisma } from "../../lib/prisma";
import { emailQueue } from "../../queues/email/email.queue";
import { AppError } from "../../utils/app_error"; import { AppError } from "../../utils/app_error";
import { jwtHelpers } from "../../utils/JWT"; import { jwtHelpers } from "../../utils/JWT";
import { otpGenerator } from "../../utils/otpGenerator";
import sendMail from "../../utils/mail_sender"; import sendMail from "../../utils/mail_sender";
import { otpGenerator } from "../../utils/otpGenerator";
const create_account_into_db = async (req: Request) => { const create_account_into_db = async (req: Request) => {
const payload = req?.body; const payload = req?.body;
// check account exist or not
const existingAccount = await prisma.account.findUnique({
where: { email: payload.email },
});
if (existingAccount) {
throw new AppError("Email already exists", 403);
}
// hash password // hash password
const hashPassword = bcrypt.hashSync(payload.password, 10); const hashPassword = bcrypt.hashSync(payload.password, 10);
@@ -24,7 +32,7 @@ const create_account_into_db = async (req: Request) => {
const profile = await tx.profile.create({ const profile = await tx.profile.create({
data: { data: {
fullName: payload.fullName, shopName: payload.shopName,
accountId: account.id, accountId: account.id,
}, },
}); });
@@ -56,22 +64,14 @@ const create_account_into_db = async (req: Request) => {
lastOtpSendingTime: new Date(), lastOtpSendingTime: new Date(),
}, },
}); });
await emailQueue.add("email-queue", {
await sendMail({ name: payload.shopName,
to: payload.email as string, otp: newOtp,
subject: "welcome to - Please verify your account", verificationLink: verificationLink,
htmlBody: ` subject: "Welcome to Quick Launch - Verification OTP",
<p><strong>OTP</strong> ${newOtp}</p> email: payload.email,
<small>Otp will be expire in 5 minutes</small> textBody: "You can use otp or verification link for verifying your account"
})
<br/> <br/>
<p>Or you can use Verification link </p>
<p>${verificationLink}</p>
`,
textBody: "You can use otp or direct link",
name: payload.fullName,
});
return result; return result;
}; };
@@ -96,7 +96,7 @@ const verify_account_using_otp_into_db = async (req: Request) => {
const OTP_EXPIRY_TIME = 5 * 60 * 1000; // 5 minutes in ms const OTP_EXPIRY_TIME = 5 * 60 * 1000; // 5 minutes in ms
const isOtpExpired = account.lastOtpSendingTime const isOtpExpired = account.lastOtpSendingTime
? new Date().getTime() - new Date(account.lastOtpSendingTime).getTime() > ? new Date().getTime() - new Date(account.lastOtpSendingTime).getTime() >
OTP_EXPIRY_TIME OTP_EXPIRY_TIME
: true; : true;
if (isOtpExpired) { if (isOtpExpired) {
@@ -316,7 +316,7 @@ const resend_otp_and_verification_link_from_db = async (req: Request) => {
}); });
}; };
const forget_password_genereate_reset_token_from_db = async (req: Request) => { const forget_password_generate_reset_token_from_db = async (req: Request) => {
const email = req?.body?.email as string; const email = req?.body?.email as string;
const account = await prisma.account.findUnique({ const account = await prisma.account.findUnique({
where: { where: {
@@ -389,6 +389,7 @@ const reset_password_using_token_into_db = async (req: Request) => {
// infuter user alart for changing password // infuter user alart for changing password
return ""; return "";
}; };
export const account_services = { export const account_services = {
create_account_into_db, create_account_into_db,
login_user_into_db, login_user_into_db,
@@ -397,6 +398,6 @@ export const account_services = {
verify_account_using_otp_into_db, verify_account_using_otp_into_db,
resend_otp_and_verification_link_from_db, resend_otp_and_verification_link_from_db,
verify_account_using_link_into_db, verify_account_using_link_into_db,
forget_password_genereate_reset_token_from_db, forget_password_generate_reset_token_from_db,
reset_password_using_token_into_db reset_password_using_token_into_db
}; };
+1 -1
View File
@@ -11,7 +11,7 @@ export const accountSwaggerDocs = {
example: JSON.stringify({ example: JSON.stringify({
email: "user@gmail.com", email: "user@gmail.com",
password: "password", password: "password",
fullName: "User", shopName: "User",
}), }),
}, },
}, },
@@ -3,7 +3,7 @@ import z from "zod";
const sign_up = z.object({ const sign_up = z.object({
email: z.string("Email is required."), email: z.string("Email is required."),
password: z.string("Password is required."), password: z.string("Password is required."),
fullName: z.string("Full name is required."), shopName: z.string("Full name is required."),
}); });
const sing_in = z.object({ const sing_in = z.object({
@@ -12,19 +12,19 @@ const sing_in = z.object({
}); });
const change_password = z.object({ const change_password = z.object({
oldPassword: z.string("Old Password is requied"), oldPassword: z.string("Old Password is required"),
newPassword: z.string("New Password is required"), newPassword: z.string("New Password is required"),
}); });
const verify_otp = z.object({ const verify_otp = z.object({
email: z.string("Email is requied"), email: z.string("Email is required"),
otp: z.string("OTP is required"), otp: z.string("OTP is required"),
}); });
const verify_link = z.object({ const verify_link = z.object({
token: z.string("Token is required "), token: z.string("Token is required "),
}); });
const resend_otp = z.object({ const resend_otp = z.object({
email: z.string("Email is requied"), email: z.string("Email is required"),
}); });
const reset_pass = z.object({ const reset_pass = z.object({
token: z.string("Token is required"), token: z.string("Token is required"),
+6
View File
@@ -0,0 +1,6 @@
import { QueueOptions } from "bullmq";
export const redisConnection: QueueOptions["connection"] = {
host: "127.0.0.1",
port: 6379,
};
+16
View File
@@ -0,0 +1,16 @@
import { otpTemplate } from "../../templates/otpTemplate";
import sendMail from "../../utils/mail_sender";
import { TEmailQueue } from "./email.queue";
// email.processor.ts
export const emailProcessor = async (job: any) => {
const payload: TEmailQueue = job.data;
await sendMail({
to: payload.email as string,
subject: payload.subject,
htmlBody: otpTemplate(payload),
textBody: payload.textBody || "",
name: payload.name,
});
console.log("Sending email job complete:", job.id);
};
+16
View File
@@ -0,0 +1,16 @@
// email.queue.ts
import { Queue } from "bullmq";
import { redisConnection } from "../connection";
export type TEmailQueue = {
email: string;
name?: string;
otp?: string;
verificationLink?: string;
subject: string;
textBody?:string
}
export const emailQueue = new Queue<TEmailQueue>("email-queue", {
connection: redisConnection,
});
+13
View File
@@ -0,0 +1,13 @@
// email.worker.ts
import { Worker } from "bullmq";
import { redisConnection } from "../connection";
import { emailProcessor } from "./email.processor";
import { TEmailQueue } from "./email.queue";
export const emailWorker = new Worker<TEmailQueue>(
"email-queue",
async (job) => emailProcessor(job),
{
connection: redisConnection,
}
);
+3
View File
@@ -0,0 +1,3 @@
import "./email/email.worker";
console.log("Workers running...");
+82
View File
@@ -0,0 +1,82 @@
import { TEmailQueue } from "../queues/email/email.queue"
export const otpTemplate = (payload: TEmailQueue) => {
return `
<div
style="
margin: 0;
padding: 0;
background-color: #f4f6f8;
font-family: Arial, Helvetica, sans-serif;
"
>
<table
align="center"
width="100%"
cellpadding="0"
cellspacing="0"
style="
max-width: 600px;
margin: auto;
background: #ffffff;
border-radius: 8px;
overflow: hidden;
"
>
<tr>
<td style="padding: 30px; color: #333333">
<p style="margin: 0 0 20px 0; font-size: 15px">
Use the following One-Time Password (OTP) to complete your
verification:
</p>
<!-- OTP Box -->
<div style="text-align: center; margin: 25px 0">
<span
style="
display: inline-block;
background: #f1f5f9;
padding: 15px 25px;
font-size: 24px;
letter-spacing: 4px;
font-weight: bold;
color: #111827;
border-radius: 6px;
"
>
${payload.otp}
</span>
</div>
<p style="margin: 0 0 20px 0; font-size: 13px; color: #6b7280">
This OTP will expire in <strong>5 minutes</strong>.
</p>
<!-- Divider -->
<hr
style="
border: none;
border-top: 1px solid #e5e7eb;
margin: 25px 0;
"
/>
<!-- Verification Link -->
<p style="margin: 0 0 10px 0; font-size: 15px">
Or verify using this link:
</p>
<p style="word-break: break-all; font-size: 14px">
<a
href="${payload.verificationLink}"
style="color: #4f46e5; text-decoration: none"
>
${payload.verificationLink}
</a>
</p>
</td>
</tr>
</table>
</div>
`
}
+74 -53
View File
@@ -26,73 +26,94 @@ const sendMail = async (payload: TMailContent) => {
subject: payload.subject, subject: payload.subject,
text: payload.textBody, text: payload.textBody,
html: ` html: `
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<head> <meta charset="UTF-8" />
<meta charset="UTF-8">
<title>Welcome Email</title> <title>Welcome Email</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style> <style>
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
}
/* Fallback styles for unsupported clients (some email clients ignore <style> tags) */
@media only screen and (max-width: 600px) {
.container {
padding: 20px !important;
} }
/* Fallback styles for unsupported clients (some email clients ignore <style> tags) */ .btn {
@media only screen and (max-width: 600px) { padding: 12px 18px !important;
.container { font-size: 16px !important;
padding: 20px !important;
}
.btn {
padding: 12px 18px !important;
font-size: 16px !important;
}
} }
}
</style> </style>
</head> </head>
<body <body style="margin: 0; padding: 0; font-family: Arial, sans-serif">
style="margin: 0; padding: 0; font-family: Arial, sans-serif;"> <div
style="
<div style="max-width: 600px; margin: 40px auto; background-color: #f4f4f4; padding: 40px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.05);" max-width: 600px;
class="container"> margin: 40px auto;
background-color: #f4f4f4;
<div style="font-size: 16px; color: #555555; line-height: 1.6;"> padding: 40px;
<p style="margin-bottom: 30px;">Hi <strong>${payload?.name || ""}</strong>,</p> border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
${payload?.htmlBody} "
class="container"
<div >
style=" margin-top: 60px; text-align: center;"> <div style="font-size: 16px; color: #555555; line-height: 1.6">
<p style="margin-bottom: 30px">
<img style="width: 50px; height: 50px; border-radius: 50%;" Hi <strong>${payload?.name || ""}</strong>,
src="https://imgs.search.brave.com/IZoN38NQxnIIuB1I9E70bW6q5OvbEtz68YaxTe1j-o0/rs:fit:860:0:0:0/g:ce/aHR0cHM6Ly9lbGVt/ZW50cy1yZXNpemVk/LmVudmF0b3VzZXJj/b250ZW50LmNvbS9l/bGVtZW50cy1jb3Zl/ci1pbWFnZXMvMjhi/NmVjMTQtMGMwOS00/NGY1LWE5NGUtNmIy/OTM5NTZkMDM2P3c9/NDMzJmNmX2ZpdD1z/Y2FsZS1kb3duJnE9/ODUmZm9ybWF0PWF1/dG8mcz04Mjc0OWYy/ZDUyMmJiM2NlMjNi/OWNhNjhlZmFhNjdk/MTg5OGI4NWIwNzBh/MjQ1NjM4NmI1ZmFj/NWVmNmM5ZTNl"
alt="">
<p style="font-size: 12px;">The Support Team</p>
<h3>Company Name</h3>
</div>
</div>
<p style="font-size: 14px; color: #999999; margin-top: 20px; margin-bottom: 10px; text-align: center;">
This is an automated message — please do not reply to this email.
<br>
If you need assistance, feel free to contact our support team.
<br><br>
Thank you for choosing us!
</p> </p>
<hr> ${payload?.htmlBody}
<div style="text-align: center; font-size: 12px; color: #999999; margin-top: 20px;">
&copy; {{year}} Your Company. All rights reserved. <div style="margin-top: 60px; text-align: center">
<img
style="width: 50px; height: 50px; border-radius: 50%"
src="https://i.ibb.co.com/RkFJjPWg/quick-launch-1.png"
alt="Quick Launch"
/>
<p style="font-size: 12px">The Support Team</p>
<h3>Quick Launch</h3>
</div> </div>
</div>
<p
style="
font-size: 14px;
color: #999999;
margin-top: 20px;
margin-bottom: 10px;
text-align: center;
"
>
This is an automated message — please do not reply to this email.
<br />
If you need assistance, feel free to contact our support team.
<br /><br />
Thank you for choosing us!
</p>
<hr />
<div
style="
text-align: center;
font-size: 12px;
color: #999999;
margin-top: 20px;
"
>
&copy; 2026 to {{year}} Quick Launch. All rights reserved.
</div>
</div> </div>
</body> </body>
</html> </html>
`, `,
}); });
+1
View File
@@ -1,6 +1,7 @@
import app from "./app"; import app from "./app";
import { configs } from "./app/configs/index"; import { configs } from "./app/configs/index";
import { prisma } from "./app/lib/prisma"; import { prisma } from "./app/lib/prisma";
import "./app/queues/worker";
async function main() { async function main() {
try { try {