feat: Implement Phase 2 cross-cutting services with email and notification enhancements

- Refactor email verification and password reset utilities to use new email service.
- Introduce email delivery queue and notification delivery model for better tracking.
- Enhance file validation and storage services for improved security and ownership management.
- Add cron job for cleaning inactive notifications with retention policy.
- Update document worker to handle document generation and storage more efficiently.
- Implement logging improvements in activity and log workers.
- Create comprehensive documentation for new API endpoints and services.
- Add unit tests for file validation and notification policies to ensure robustness.
This commit is contained in:
Sathira Sri Sathara
2026-09-03 14:22:34 +05:30
parent 9d3d431416
commit b6b345f245
54 changed files with 593 additions and 1248 deletions
+3 -3
View File
@@ -23,11 +23,11 @@ const log = async (...args) => {
const message = args
.map((arg) => {
if (arg instanceof Error) {
return `${arg.message}\n${arg.stack}`;
return arg.message;
}
if (typeof arg === "object") {
return JSON.stringify(arg);
return JSON.stringify(arg, (key, value) => /password|otp|token|authorization|cookie|secret/i.test(key) ? "[REDACTED]" : value);
}
return String(arg);
@@ -35,7 +35,7 @@ const log = async (...args) => {
.join(" ");
await logQueue.add("log", {
message,
level: "info", service: "zumri-api", message,
timestamp: new Date().toISOString(),
});
} catch (err) {
+1 -1
View File
@@ -9,7 +9,7 @@
// app/utils/documentJob.util.js
const documentQueue = require("../queues/pdf.queue");
const documentQueue = require("../queues/document.queue");
/**
* Queue a document generation job
+5 -12
View File
@@ -1,7 +1,7 @@
// app/utils/emailVerification.util.js
const crypto = require("crypto");
const { sendMail } = require("../utils/mail.util");
const emailService = require("../services/email/email.service");
const redis = require("../config/redisClient");
const EMAIL_VERIFICATION_TTL =
@@ -67,19 +67,12 @@ const sendVerificationEmail = async (email, firstName, verificationToken) => {
const confirmationLink = `${process.env.FRONTEND_URL}/verify-email?token=${verificationToken}`;
// Send email
await sendMail({
to: email,
subject: "Confirm Your ZUMRI Account",
templateName: "emailVerification",
templateVars: {
await emailService.send({
recipient: email, templateKey: "emailVerification",
variables: {
customer_name: firstName,
confirmation_link: confirmationLink,
},
text: `Hello ${firstName}, please verify your ZUMRI account using this link: ${confirmationLink}`,
}, eventId: `verify-${hashEmailVerificationToken(verificationToken)}`,
});
};
+4 -2
View File
@@ -32,12 +32,14 @@ if (process.env.NODE_ENV !== "test" && process.env.ENABLE_MAIL === "true") {
// Utility to load template and replace placeholders
const loadTemplate = (templateName, variables = {}) => {
if (!/^[a-zA-Z0-9_-]+$/.test(templateName)) throw new Error("Invalid email template");
const templatePath = path.join(__dirname, "../templates/emails", `${templateName}.html`);
let template = fs.readFileSync(templatePath, "utf-8");
Object.keys(variables).forEach((key) => {
const regex = new RegExp(`{{${key}}}`, "g");
template = template.replace(regex, variables[key]);
const escaped = String(variables[key] ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
template = template.replace(regex, escaped);
});
return template;
@@ -90,5 +92,5 @@ const sendMail = async (options) => {
};
module.exports = { sendMail };
module.exports = { sendMail, loadTemplate };
+8 -28
View File
@@ -11,7 +11,7 @@
const crypto = require("crypto");
const redis = require("../config/redisClient");
const { sendMail } = require("./mail.util");
const emailService = require("../services/email/email.service");
const PASSWORD_RESET_TTL =
Number(process.env.PASSWORD_RESET_TTL_SECONDS) || 900;
@@ -81,23 +81,13 @@ const sendPasswordResetEmail =
`${process.env.FRONTEND_URL}/reset-password?token=${resetToken}`;
await sendMail({
to: email,
subject:
"Reset your ZUMRI password",
templateName:
"passwordReset",
templateVars: {
await emailService.send({
recipient: email, templateKey: "passwordReset",
variables: {
firstName: firstName,
resetLink: resetLink,
expiryTime: "15 minutes",
},
text:
`Hi ${firstName}, use this link within 15 minutes to reset your password: ${resetLink}`,
}, eventId: `reset-${hashPasswordResetToken(resetToken)}`,
});
};
@@ -109,25 +99,15 @@ const sendPasswordResetEmail =
const changedAt =
new Date().toLocaleString();
await sendMail({
to: email,
subject:
"Your ZUMRI password was changed",
templateName:
"passwordChanged",
templateVars: {
await emailService.send({
recipient: email, templateKey: "passwordChanged",
variables: {
customer_name:
firstName,
changed_at:
changedAt,
},
text:
`Hi ${firstName}, your password was changed at ${changedAt}. If this was not you, contact support immediately.`,
});
};
+6 -82
View File
@@ -1,89 +1,13 @@
/**
* Copyright (c) 2026 Niolla
* All rights reserved.
*
* This source code is proprietary and confidential.
* Unauthorized copying, modification, distribution, or use
* of this file, via any medium, is strictly prohibited.
*/
const storage = require("../services/storage/storage.service");
// app/utils/s3Upload.util.js
const { PutObjectCommand, GetObjectCommand } = require("@aws-sdk/client-s3");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
const s3 = require("../config/s3.config");
const { v4: uuidv4 } = require("uuid");
const path = require("path");
const redis = require("../config/redisClient");
const { log } = require("./consoleLog.utill");
// Upload + return key (BEST PRACTICE)
const uploadToS3 = async (file, usedFor) => {
try {
const fileExtension = path.extname(file.originalname);
const fileName = `${uuidv4()}${fileExtension}`;
let key = `uploads/${fileName}`;
if (usedFor === "asset") {
key = `assets/${fileName}`;
}
log("Uploading file to S3 with key:", key);
const params = {
Bucket: process.env.AWS_S3_BUCKET_NAME,
Key: key,
Body: file.buffer,
ContentType: file.mimetype,
};
await s3.send(new PutObjectCommand(params));
// Return KEY (not URL)
return key;
} catch (error) {
log("S3 Upload Error:", error);
throw new Error("File upload failed");
}
};
// Generate signed URL with Redis caching
const getSignedFileUrl = async (key) => {
try {
const cacheKey = `s3:url:${key}`;
// 1. Check cache
const cachedUrl = await redis.get(cacheKey);
if (cachedUrl) {
log("⚡ Cache HIT - returning signed URL from Redis");
return cachedUrl;
}
log("🐢 Cache MISS - generating new signed URL");
// 2. Generate signed URL
const command = new GetObjectCommand({
Bucket: process.env.AWS_S3_BUCKET_NAME,
Key: key,
});
const signedUrl = await getSignedUrl(s3, command, {
expiresIn: 3600, // 1 hour
});
// 3. Store in Redis (TTL: 55 minutes)
await redis.set(cacheKey, signedUrl, "EX", 3300);
return signedUrl;
} catch (error) {
log("Signed URL Error:", error);
throw new Error("Failed to generate file URL");
}
const key = storage.createObjectKey({ ownerId: file.ownerId, mimeType: file.mimetype, purpose: usedFor });
await storage.uploadBuffer({ buffer: file.buffer, objectKey: key, mimeType: file.mimetype, checksum: file.checksum });
return key;
};
module.exports = {
uploadToS3,
getSignedFileUrl,
getSignedFileUrl: storage.createSignedDownloadUrl,
deleteFromS3: storage.deleteObject,
};