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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}`,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]);
|
||||
template = template.replace(regex, escaped);
|
||||
});
|
||||
|
||||
return template;
|
||||
@@ -90,5 +92,5 @@ const sendMail = async (options) => {
|
||||
};
|
||||
|
||||
|
||||
module.exports = { sendMail };
|
||||
module.exports = { sendMail, loadTemplate };
|
||||
|
||||
|
||||
@@ -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.`,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user