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
+18 -6
View File
@@ -15,21 +15,33 @@ const logActivity = async ({
user,
description,
type = "ACTION",
module
module,
eventId,
targetType,
targetId,
requestId,
ipAddress,
userAgent,
metadata,
}) => {
try {
const safeMetadata = metadata && JSON.parse(JSON.stringify(metadata, (key, value) => /password|otp|token|authorization|cookie/i.test(key) ? "[REDACTED]" : value));
const id = eventId || require("crypto").randomUUID();
await activityQueue.add("log-activity", {
user_id: user.id || user.user_id,
username: user.firstName,
event_id: id,
user_id: user?.id || user?.user_id || null,
username: user?.firstName || "System",
activity_description: description,
module: module,
activity_type: type,
activity_time: new Date(),
activity_date: new Date().toISOString().split("T")[0]
});
activity_date: new Date().toISOString().split("T")[0],
target_type: targetType, target_id: targetId, request_id: requestId,
ip_address: ipAddress, user_agent: userAgent, metadata: safeMetadata, occurred_at: new Date(),
}, { jobId: `activity-${id}` });
} catch (err) {
console.error("Queue push error:", err.message);
}
};
module.exports = { logActivity };
module.exports = { logActivity };
+3 -3
View File
@@ -1,6 +1,6 @@
const { sendMail } = require("../../utils/mail.util");
const emailService = require("../email/email.service");
const sendLoginOtp = (user, otp) => sendMail({ to: user.email, subject: "Your ZUMRI login code", templateName: "otp", templateVars: { firstName: user.firstName, otp }, text: `Your ZUMRI login code is ${otp}.` });
const sendPasswordChanged = (user) => sendMail({ to: user.email, subject: "Your ZUMRI password was changed", templateName: "passwordChanged", templateVars: { customer_name: user.firstName, changed_at: new Date().toLocaleString() }, text: "Your ZUMRI password was changed. Contact support if this was not you." });
const sendLoginOtp = (user, otp) => emailService.send({ templateKey: "otp", recipient: user.email, userId: user.id, variables: { firstName: user.firstName, otp } });
const sendPasswordChanged = (user) => emailService.send({ templateKey: "passwordChanged", recipient: user.email, userId: user.id, variables: { customer_name: user.firstName, changed_at: new Date().toLocaleString() } });
module.exports = { sendLoginOtp, sendPasswordChanged };
+15
View File
@@ -0,0 +1,15 @@
const crypto = require("crypto");
const emailQueue = require("../../queues/email.queue");
const db = require("../../models");
const templates = {
otp: { subject: "Your ZUMRI login code" }, passwordChanged: { subject: "Your ZUMRI password was changed" },
passwordReset: { subject: "Reset your ZUMRI password" }, emailVerification: { subject: "Verify your ZUMRI email" }, welcome: { subject: "Welcome to ZUMRI" },
};
const send = async ({ templateKey, recipient, locale = "en", variables = {}, correlationId, notificationId, userId, eventId }) => {
if (!templates[templateKey]) throw new Error("Unknown email template");
const id = crypto.randomUUID();
await db.NotificationDelivery.create({ id, notificationId, userId, channel: "EMAIL", recipient, templateKey, status: "QUEUED" });
await emailQueue.add("send-email", { deliveryId: id, templateKey, recipient, locale, variables, correlationId }, { jobId: `email-${eventId || id}` });
return { deliveryId: id };
};
module.exports = { send, templates };
@@ -0,0 +1,7 @@
const MANDATORY_SECURITY_EVENTS = new Set(["LOGIN_OTP", "PASSWORD_RESET", "PASSWORD_CHANGED", "EMAIL_VERIFICATION"]);
const channelAllowed = ({ eventType, channel, profile }) => {
if (MANDATORY_SECURITY_EVENTS.has(eventType)) return true;
if (channel === "IN_APP") return true;
return profile?.notificationsEnabled !== false;
};
module.exports = { MANDATORY_SECURITY_EVENTS, channelAllowed };
@@ -0,0 +1,19 @@
const crypto = require("crypto");
const { extensionFor, sanitizeFilename } = require("./storage.service");
const detectMimeType = (buffer) => {
if (!Buffer.isBuffer(buffer) || !buffer.length) return null;
if (buffer.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) return "image/jpeg";
if (buffer.subarray(0, 8).equals(Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]))) return "image/png";
if (buffer.length >= 12 && buffer.toString("ascii", 0, 4) === "RIFF" && buffer.toString("ascii", 8, 12) === "WEBP") return "image/webp";
if (buffer.subarray(0, 5).toString("ascii") === "%PDF-") return "application/pdf";
if (buffer.subarray(0, 4).equals(Buffer.from([0x50,0x4b,0x03,0x04]))) return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
return null;
};
const validateUpload = (file) => {
if (!file?.buffer?.length) throw Object.assign(new Error("Empty files are not allowed"), { status: 400 });
if (file.buffer.length > Number(process.env.S3_MAX_UPLOAD_BYTES || 5242880)) throw Object.assign(new Error("File exceeds the upload size limit"), { status: 413 });
const mimeType = detectMimeType(file.buffer);
if (!mimeType || mimeType !== file.mimetype || !extensionFor(mimeType)) throw Object.assign(new Error("File content does not match an allowed type"), { status: 400 });
return { mimeType, size: file.buffer.length, checksum: crypto.createHash("sha256").update(file.buffer).digest("hex"), safeName: `${sanitizeFilename(file.originalname).replace(/\.[^.]+$/, "")}${extensionFor(mimeType)}` };
};
module.exports = { detectMimeType, validateUpload };
+19
View File
@@ -0,0 +1,19 @@
const crypto = require("crypto");
const path = require("path");
const { PutObjectCommand, DeleteObjectCommand, HeadObjectCommand, GetObjectCommand } = require("@aws-sdk/client-s3");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
const s3 = require("../../config/s3.config");
const bucket = () => process.env.AWS_S3_BUCKET_NAME;
const extensionFor = (mime) => ({ "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp", "application/pdf": ".pdf", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx" }[mime]);
const sanitizeFilename = (name = "file") => path.basename(name).replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 180);
const createObjectKey = ({ ownerId = "system", mimeType, purpose = "upload", now = new Date(), id = crypto.randomUUID() }) => {
const ext = extensionFor(mimeType); if (!ext) throw new Error("Unsupported file type");
const root = purpose === "document" ? "documents" : "uploads";
const owner = String(ownerId).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 80);
return `${root}/${owner}/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, "0")}/${id}${ext}`;
};
const uploadBuffer = async ({ buffer, objectKey, mimeType, checksum }) => { await s3.send(new PutObjectCommand({ Bucket: bucket(), Key: objectKey, Body: buffer, ContentType: mimeType, ChecksumSHA256: checksum ? Buffer.from(checksum, "hex").toString("base64") : undefined })); return objectKey; };
const deleteObject = (objectKey) => s3.send(new DeleteObjectCommand({ Bucket: bucket(), Key: objectKey }));
const objectExists = async (objectKey) => { try { await s3.send(new HeadObjectCommand({ Bucket: bucket(), Key: objectKey })); return true; } catch (error) { if (error.name === "NotFound" || error.$metadata?.httpStatusCode === 404) return false; throw error; } };
const createSignedDownloadUrl = (objectKey, expiresIn = Number(process.env.S3_SIGNED_URL_TTL_SECONDS || 900)) => getSignedUrl(s3, new GetObjectCommand({ Bucket: bucket(), Key: objectKey }), { expiresIn });
module.exports = { uploadBuffer, deleteObject, objectExists, createSignedDownloadUrl, createObjectKey, extensionFor, sanitizeFilename };