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:
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user