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:
@@ -16,13 +16,14 @@ const { BullMQAdapter } = require("@bull-board/api/bullMQAdapter");
|
||||
const activityQueue = require("../queues/activity.queue");
|
||||
const documentQueue = require("../queues/document.queue");
|
||||
const logQueue = require("../queues/log.queue");
|
||||
const emailQueue = require("../queues/email.queue");
|
||||
|
||||
const serverAdapter = new ExpressAdapter();
|
||||
serverAdapter.setBasePath("/admin/queues");
|
||||
|
||||
const { addQueue, removeQueue, setQueues, replaceQueues } =
|
||||
createBullBoard({
|
||||
queues: [activityQueue, documentQueue, logQueue].map((queue) => new BullMQAdapter(queue)),
|
||||
queues: [activityQueue, documentQueue, logQueue, emailQueue].map((queue) => new BullMQAdapter(queue)),
|
||||
serverAdapter,
|
||||
});
|
||||
|
||||
|
||||
@@ -38,6 +38,13 @@ const envSchema = z.object({
|
||||
MAIL_PASS: z.string().optional(), MAIL_FROM: z.string().optional(),
|
||||
AWS_REGION: z.string().optional(), AWS_ACCESS_KEY_ID: z.string().optional(),
|
||||
AWS_SECRET_ACCESS_KEY: z.string().optional(), AWS_S3_BUCKET_NAME: z.string().optional(),
|
||||
S3_ENDPOINT: z.string().url().optional(), S3_FORCE_PATH_STYLE: booleanString,
|
||||
S3_SIGNED_URL_TTL_SECONDS: z.coerce.number().int().min(60).max(86400).default(900),
|
||||
S3_MAX_UPLOAD_BYTES: z.coerce.number().int().positive().default(5242880),
|
||||
EMAIL_QUEUE_CONCURRENCY: z.coerce.number().int().positive().default(5),
|
||||
DOCUMENT_QUEUE_CONCURRENCY: z.coerce.number().int().positive().default(2),
|
||||
NOTIFICATION_RETENTION_DAYS: z.coerce.number().int().positive().default(90),
|
||||
LOG_RETENTION_DAYS: z.coerce.number().int().positive().default(30),
|
||||
DOCS_USER: z.string().optional(), DOCS_PASS: z.string().optional(),
|
||||
GOOGLE_CLIENT_ID: z.string().optional(), APPLE_CLIENT_ID: z.string().optional(),
|
||||
}).superRefine((env, context) => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
const activityQueue = require("../queues/activity.queue");
|
||||
const documentQueue = require("../queues/document.queue");
|
||||
const logQueue = require("../queues/log.queue");
|
||||
const emailQueue = require("../queues/email.queue");
|
||||
|
||||
const closeQueues = async () => {
|
||||
await Promise.allSettled([activityQueue.close(), documentQueue.close(), logQueue.close()]);
|
||||
await Promise.allSettled([activityQueue.close(), documentQueue.close(), logQueue.close(), emailQueue.close()]);
|
||||
};
|
||||
|
||||
module.exports = { closeQueues };
|
||||
|
||||
@@ -7,5 +7,7 @@ module.exports = process.env.ENABLE_S3 === "true"
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
},
|
||||
...(process.env.S3_ENDPOINT ? { endpoint: process.env.S3_ENDPOINT } : {}),
|
||||
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true",
|
||||
})
|
||||
: { send: async () => { throw new Error("S3 functionality is not enabled"); } };
|
||||
|
||||
@@ -13,4 +13,8 @@
|
||||
module.exports = {
|
||||
FINANCE_BASIS: "finance.basis",
|
||||
PRECOST: "precost.precost",
|
||||
};
|
||||
MEDIA_READ: "media.read", MEDIA_UPLOAD: "media.upload", MEDIA_DELETE: "media.delete",
|
||||
DOCUMENTS_READ: "documents.read", DOCUMENTS_CREATE: "documents.create", DOCUMENTS_DELETE: "documents.delete",
|
||||
NOTIFICATIONS_MANAGE: "notifications.manage", NOTIFICATIONS_READ: "notifications.read",
|
||||
AUDIT_READ: "audit.read", QUEUES_READ: "queues.read",
|
||||
};
|
||||
|
||||
@@ -1,417 +1,21 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// app/controllers/generateDocument.controller.js
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const documentQueue = require("../queues/document.queue");
|
||||
const { createDocumentData } = require("../utils/document.utill");
|
||||
const {
|
||||
generateId,
|
||||
generateDocumentReferenceNo,
|
||||
} = require("../utils/idGen.util");
|
||||
|
||||
const { GetObjectCommand, DeleteObjectCommand } = require("@aws-sdk/client-s3");
|
||||
|
||||
const {log} = require("../utils/consoleLog.utill");
|
||||
|
||||
const s3 = require("../config/s3.config");
|
||||
const crypto = require("crypto");
|
||||
const { z } = require("zod");
|
||||
const db = require("../models");
|
||||
const Document = db.Document;
|
||||
const DocumentType = db.DocumentType;
|
||||
const documentQueue = require("../queues/document.queue");
|
||||
const registry = require("../logic/documents/registry");
|
||||
const storage = require("../services/storage/storage.service");
|
||||
|
||||
const normalizeDocumentData = (value) => {
|
||||
if (!value) {
|
||||
return {};
|
||||
}
|
||||
const requestSchema = z.object({ document: z.string().min(1).max(64), documentType: z.enum(["pdf", "excel"]), documentData: z.record(z.string(), z.unknown()).optional(), data: z.record(z.string(), z.unknown()).optional() }).passthrough();
|
||||
const normalize = (value) => String(value).toLowerCase().replace(/[\s_-]+/g, "");
|
||||
const allowed = (user, doc, permission = "documents.read") => doc.owner_id === user.id || doc.created_by === user.id || user.accountType === "super_admin" || (user.permissions || []).includes(permission);
|
||||
const publicDoc = (doc) => ({ id: doc.doc_id, referenceNo: doc.reference_no, documentType: doc.doc_type, status: doc.status, jobId: doc.job_id, generatedAt: doc.generated_at, failedAt: doc.failed_at, failureCode: doc.failure_code, createdAt: doc.createdAt });
|
||||
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get available document types
|
||||
*/
|
||||
exports.getAvailableDocumentTypes = async (req, res) => {
|
||||
try {
|
||||
|
||||
const doc_types = await DocumentType.findAll({
|
||||
attributes: ["doc_type_name"],
|
||||
});
|
||||
|
||||
const types = doc_types.map((t) => t.doc_type_name);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
availableDocumentTypes: types,
|
||||
note: "Use these document type names in your requests (case-insensitive)",
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
log("Error fetching document types:", error.message);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get Saved documents
|
||||
exports.getSavedDocuments = async (req, res) => {
|
||||
try {
|
||||
const { documentType } = req.body;
|
||||
|
||||
if (!documentType) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "documentType query parameter is required",
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch saved documents based on documentType
|
||||
const savedDocuments = await Document.findAll({
|
||||
where: { doc_type: documentType.toUpperCase() },
|
||||
order: [["createdAt", "DESC"]],
|
||||
exclude: ["data"],
|
||||
});
|
||||
|
||||
// extract only necessary fields to return
|
||||
const formattedDocuments = savedDocuments.map((doc) => ({
|
||||
doc_id: doc.doc_id,
|
||||
reference_no: doc.reference_no,
|
||||
doc_type: doc.doc_type,
|
||||
status: doc.status,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
}));
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
savedDocuments: formattedDocuments,
|
||||
});
|
||||
} catch (error) {
|
||||
log("Error fetching saved documents:", error.message);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get specific document data by doc_id
|
||||
exports.getDocumentData = async (req, res) => {
|
||||
try {
|
||||
const { docId } = req.params;
|
||||
|
||||
if (!docId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "docId parameter is required",
|
||||
});
|
||||
}
|
||||
|
||||
const document = await Document.findOne({
|
||||
where: { doc_id: docId },
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "Document not found",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
document: {
|
||||
doc_id: document.doc_id,
|
||||
reference_no: document.reference_no,
|
||||
doc_type: document.doc_type,
|
||||
data: document.data,
|
||||
status: document.status,
|
||||
createdAt: document.createdAt,
|
||||
updatedAt: document.updatedAt,
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
log("Error fetching document data:", error.message);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
exports.generateReferenceNo = async (req, res) => {
|
||||
try {
|
||||
const { documentType } = req.params;
|
||||
|
||||
if (!documentType) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "documentType is required",
|
||||
});
|
||||
}
|
||||
|
||||
// Generate reference number
|
||||
const reference_no = await generateDocumentReferenceNo(documentType);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
reference_no,
|
||||
});
|
||||
} catch (error) {
|
||||
log("Error generating reference number:", error.message);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.generateDraftDocument = async (req, res) => {
|
||||
try {
|
||||
const { document } = req.body;
|
||||
const documentData = normalizeDocumentData(req.body.documentData || req.body.data || req.body);
|
||||
|
||||
// Validation
|
||||
if (!document || !documentData) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "document and documentData are required",
|
||||
});
|
||||
}
|
||||
|
||||
let documentDetails;
|
||||
|
||||
if (document !== "PRECOST") {
|
||||
// Save document details before generating
|
||||
documentDetails = await createDocumentData(
|
||||
document,
|
||||
documentData,
|
||||
"DRAFT",
|
||||
);
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"Invalid document type, This document type is not allowed to be generated as draft",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
message: "Draft document created successfully",
|
||||
documentDetails,
|
||||
});
|
||||
} catch (error) {
|
||||
log("Error generating draft document:", error.message);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate document asynchronously
|
||||
* Returns jobId immediately
|
||||
*/
|
||||
exports.generateDocument = async (req, res) => {
|
||||
try {
|
||||
const { document, documentType } = req.body;
|
||||
const documentData = normalizeDocumentData(req.body.documentData || req.body.data || req.body);
|
||||
|
||||
// Validation
|
||||
if (!document || !documentType || !documentData) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "document, documentType, and documentData are required",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
`📨 generateDocument request: document="${document}", documentType="${documentType}"`,
|
||||
);
|
||||
|
||||
if (document !== "PRECOST") {
|
||||
// Save document details before generating
|
||||
// If doc_id exists, finalize (update existing); otherwise create as DRAFT
|
||||
const status = documentData.doc_id ? "FINAL" : "DRAFT";
|
||||
await createDocumentData(document, documentData, status);
|
||||
}
|
||||
|
||||
// Add job to queue
|
||||
const job = await documentQueue.add("generate-document", {
|
||||
document,
|
||||
documentType,
|
||||
data: documentData,
|
||||
});
|
||||
|
||||
log(
|
||||
`📋 Document generation job queued: ${job.id} (document="${document}", type="${documentType}")`,
|
||||
);
|
||||
|
||||
return res.status(202).json({
|
||||
success: true,
|
||||
message: "Document generation started",
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
log("Error queuing document generation:", error.message);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get job status and result
|
||||
*/
|
||||
exports.getJobStatus = async (req, res) => {
|
||||
try {
|
||||
const { jobId } = req.params;
|
||||
|
||||
if (!jobId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "jobId is required",
|
||||
});
|
||||
}
|
||||
|
||||
// Get job from queue
|
||||
const job = await documentQueue.getJob(jobId);
|
||||
|
||||
if (!job) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "Job not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Get job state
|
||||
const state = await job.getState();
|
||||
const result = job.returnvalue;
|
||||
const failedReason = job.failedReason;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
jobId: job.id,
|
||||
state, // "waiting" | "active" | "completed" | "failed" | "delayed"
|
||||
result: state === "completed" ? result : null,
|
||||
error: state === "failed" ? failedReason : null,
|
||||
attempts: job.attemptsMade,
|
||||
stacktrace: job.stacktrace,
|
||||
});
|
||||
} catch (error) {
|
||||
log("Error fetching job status:", error.message);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Download generated document
|
||||
*/
|
||||
exports.downloadDocument = async (req, res) => {
|
||||
try {
|
||||
const { uuid } = req.params;
|
||||
|
||||
const key = `uploads/${uuid}.pdf`;
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: process.env.AWS_S3_BUCKET_NAME,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
const response = await s3.send(command);
|
||||
|
||||
res.setHeader(
|
||||
"Content-Type",
|
||||
response.ContentType || "application/octet-stream",
|
||||
);
|
||||
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${uuid}.pdf"`);
|
||||
|
||||
response.Body.pipe(res);
|
||||
|
||||
res.on("finish", async () => {
|
||||
try {
|
||||
await s3.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: process.env.AWS_S3_BUCKET_NAME,
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
|
||||
log(`Deleted from S3: ${key}`);
|
||||
} catch (err) {
|
||||
log(`Failed to delete ${key}:`, err.message);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
log("Download error:", error.message);
|
||||
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "Document not found",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancel/delete a job
|
||||
*/
|
||||
exports.cancelJob = async (req, res) => {
|
||||
try {
|
||||
const { jobId } = req.params;
|
||||
|
||||
if (!jobId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "jobId is required",
|
||||
});
|
||||
}
|
||||
|
||||
const job = await documentQueue.getJob(jobId);
|
||||
|
||||
if (!job) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "Job not found",
|
||||
});
|
||||
}
|
||||
|
||||
await job.remove();
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "Job cancelled successfully",
|
||||
jobId,
|
||||
});
|
||||
} catch (error) {
|
||||
log("Error cancelling job:", error.message);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
exports.getAvailableDocumentTypes = async (_req, res) => res.json({ success: true, data: Object.keys(registry) });
|
||||
exports.getSavedDocuments = async (req, res, next) => { try { const docs = await db.Document.findAll({ where: { owner_id: req.user.id }, attributes: { exclude: ["data"] }, order: [["createdAt", "DESC"]] }); return res.json({ success: true, data: docs.map(publicDoc) }); } catch (e) { return next(e); } };
|
||||
exports.getDocumentData = async (req, res, next) => { try { const doc = await db.Document.findByPk(req.params.docId); if (!doc) return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Document not found" } }); if (!allowed(req.user, doc)) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } }); return res.json({ success: true, data: { ...publicDoc(doc), documentData: doc.data } }); } catch (e) { return next(e); } };
|
||||
exports.generateReferenceNo = (_req, res) => res.status(410).json({ success: false, error: { code: "DEPRECATED", message: "Reference numbers are assigned during document creation" } });
|
||||
exports.generateDraftDocument = async (req, res, next) => { try { const parsed = requestSchema.safeParse({ ...req.body, documentType: req.body.documentType || "pdf" }); if (!parsed.success) return res.status(400).json({ success: false, error: { code: "VALIDATION_ERROR", message: "Invalid document request" } }); const key = normalize(parsed.data.document); if (!registry[key]) return res.status(400).json({ success: false, error: { code: "INVALID_DOCUMENT_TYPE", message: "Unsupported document type" } }); const doc = await db.Document.create({ doc_id: crypto.randomUUID(), reference_no: "N/A", doc_type: key.toUpperCase(), data: parsed.data.documentData || parsed.data.data || {}, status: "DRAFT", created_by: req.user.id, owner_type: "USER", owner_id: req.user.id }); return res.status(201).json({ success: true, data: publicDoc(doc) }); } catch (e) { return next(e); } };
|
||||
exports.generateDocument = async (req, res, next) => { try { const parsed = requestSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ success: false, error: { code: "VALIDATION_ERROR", message: "Invalid document request", details: parsed.error.issues.map(i => ({ path: i.path.join("."), message: i.message })) } }); const key = normalize(parsed.data.document); const entry = registry[key]; if (!entry || (parsed.data.documentType === "excel" && !entry.excelBuilder)) return res.status(400).json({ success: false, error: { code: "INVALID_DOCUMENT_TYPE", message: "Document generator is unavailable" } }); const id = crypto.randomUUID(); const data = parsed.data.documentData || parsed.data.data || {}; const doc = await db.Document.create({ doc_id: id, reference_no: data.reference_no || "N/A", doc_type: key.toUpperCase(), data, status: "QUEUED", created_by: req.user.id, owner_type: "USER", owner_id: req.user.id, job_id: `document-${id}` }); try { await documentQueue.add("generate-document", { documentId: id, document: key, documentType: parsed.data.documentType, data }, { jobId: `document-${id}` }); } catch (e) { await doc.update({ status: "FAILED", failed_at: new Date(), failure_code: "QUEUE_FAILED" }); throw e; } return res.status(202).json({ success: true, data: publicDoc(doc) }); } catch (e) { return next(e); } };
|
||||
exports.getJobStatus = async (req, res, next) => { try { const doc = await db.Document.findOne({ where: { job_id: req.params.jobId } }); if (!doc) return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Job not found" } }); if (!allowed(req.user, doc)) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } }); return res.json({ success: true, data: publicDoc(doc) }); } catch (e) { return next(e); } };
|
||||
exports.downloadDocument = async (req, res, next) => { try { const doc = await db.Document.findByPk(req.params.docId, { include: [{ model: db.Upload, as: "storageUpload" }] }); if (!doc || doc.status !== "COMPLETED" || !doc.storageUpload) return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Document not available" } }); if (!allowed(req.user, doc)) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } }); const expiresIn = Number(process.env.S3_SIGNED_URL_TTL_SECONDS || 900); return res.json({ success: true, data: { url: await storage.createSignedDownloadUrl(doc.storageUpload.file_path, expiresIn), expiresIn } }); } catch (e) { return next(e); } };
|
||||
exports.cancelJob = async (req, res, next) => { try { const doc = await db.Document.findOne({ where: { job_id: req.params.jobId } }); if (!doc) return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Job not found" } }); if (!allowed(req.user, doc, "documents.delete")) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } }); const job = await documentQueue.getJob(doc.job_id); if (job) await job.remove(); await doc.update({ status: "FAILED", failed_at: new Date(), failure_code: "CANCELLED" }); return res.json({ success: true }); } catch (e) { return next(e); } };
|
||||
|
||||
@@ -1,138 +1,20 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// app/controllers/notification.controller.js
|
||||
|
||||
const crypto = require("crypto");
|
||||
const db = require("../models");
|
||||
const Notification = db.notification;
|
||||
const UserNotification = db.userNotification;
|
||||
const { Op } = require("sequelize");
|
||||
|
||||
const log = require("../utils/consoleLog.utill").log;
|
||||
|
||||
// Controller for managing notifications
|
||||
exports.createNotification = async (req, res) => {
|
||||
exports.createNotification = async (req, res, next) => {
|
||||
const { notificationHeadline, notificationDescription, notificationType, userIds = [] } = req.body;
|
||||
if (!notificationHeadline || !["USER", "ANNOUNCEMENT"].includes(notificationType)) return res.status(400).json({ success: false, error: { code: "VALIDATION_ERROR", message: "Valid headline and notificationType are required" } });
|
||||
if (notificationType === "USER" && (!Array.isArray(userIds) || !userIds.length)) return res.status(400).json({ success: false, error: { code: "VALIDATION_ERROR", message: "USER notifications require userIds" } });
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
const { notificationHeadline, notificationDescription, notificationType } =
|
||||
req.body;
|
||||
|
||||
const id = Date.now().toString(); // Generate a unique ID based on the current timestamp
|
||||
const notification_id = `notif_${id}`; // Prefix the ID with "notif_"
|
||||
|
||||
// Create a new notification
|
||||
const newNotification = await Notification.create({
|
||||
notification_id,
|
||||
notificationHeadline,
|
||||
notificationDescription,
|
||||
notificationType,
|
||||
dateCreated: new Date(),
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: "Notification created successfully",
|
||||
notification: newNotification,
|
||||
});
|
||||
} catch (error) {
|
||||
log("Error creating notification:", error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "Internal server error",
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
const notification = await db.notification.create({ notification_id: `notif_${crypto.randomUUID()}`, notificationHeadline, notificationDescription, notificationType, dateCreated: new Date() }, { transaction });
|
||||
const uniqueUsers = [...new Set(userIds)];
|
||||
if (uniqueUsers.length) await db.userNotification.bulkCreate(uniqueUsers.map((user_id) => ({ user_id, notification_id: notification.notification_id })), { transaction, ignoreDuplicates: true });
|
||||
await transaction.commit(); return res.status(201).json({ success: true, data: { notification, assignedUsers: uniqueUsers.length } });
|
||||
} catch (error) { await transaction.rollback(); return next(error); }
|
||||
};
|
||||
|
||||
// Mark a notification as read for a user
|
||||
exports.markAsRead = async (req, res) => {
|
||||
try {
|
||||
const { userId, notificationId } = req.params;
|
||||
|
||||
// Find the user notification entry
|
||||
const userNotification = await UserNotification.findOne({
|
||||
where: { user_id: userId, notification_id: notificationId },
|
||||
});
|
||||
|
||||
if (!userNotification) {
|
||||
return res.status(404).json({ success: false, error: "User notification not found" });
|
||||
}
|
||||
|
||||
// Mark the notification as read
|
||||
userNotification.isRead = true;
|
||||
await userNotification.save();
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "Notification marked as read",
|
||||
});
|
||||
}
|
||||
|
||||
catch (error) {
|
||||
log("Error marking notification as read:", error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "Internal server error",
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get announcements for all users
|
||||
exports.getAnnouncements = async (req, res) => {
|
||||
try {
|
||||
|
||||
// Fetch all announcements
|
||||
const announcements = await Notification.findAll({
|
||||
where: { notificationType: "ANNOUNCEMENT", isActive: true },
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
announcements,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
log("Error fetching announcements:", error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "Internal server error",
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get all notifications for a user
|
||||
exports.getUserNotifications = async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
|
||||
// Fetch notifications for the user
|
||||
const notifications = await UserNotification.findAll({
|
||||
where: { user_id: userId, isRead: false },
|
||||
include: [
|
||||
{
|
||||
model: Notification,
|
||||
as: "notification",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
notifications,
|
||||
});
|
||||
}
|
||||
|
||||
catch (error) {
|
||||
log("Error fetching user notifications:", error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "Internal server error",
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
exports.getAnnouncements = async (_req, res, next) => { try { return res.json({ success: true, data: await db.notification.findAll({ where: { notificationType: "ANNOUNCEMENT", isActive: true }, order: [["createdAt", "DESC"]] }) }); } catch (e) { return next(e); } };
|
||||
exports.getMyNotifications = async (req, res, next) => { try { const rows = await db.userNotification.findAll({ where: { user_id: req.user.id }, include: [{ model: db.notification, as: "notification", where: { isActive: true } }], order: [["createdAt", "DESC"]] }); return res.json({ success: true, data: rows }); } catch (e) { return next(e); } };
|
||||
exports.markAsRead = async (req, res, next) => { try { const [count] = await db.userNotification.update({ isRead: true }, { where: { user_id: req.user.id, notification_id: req.params.notificationId } }); if (!count) return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Notification not found" } }); return res.json({ success: true }); } catch (e) { return next(e); } };
|
||||
exports.markAllAsRead = async (req, res, next) => { try { const [count] = await db.userNotification.update({ isRead: true }, { where: { user_id: req.user.id, isRead: false } }); return res.json({ success: true, data: { updated: count } }); } catch (e) { return next(e); } };
|
||||
|
||||
@@ -28,20 +28,21 @@ const { log } = require("../utils/consoleLog.utill");
|
||||
// Get Profile avatar by user ID
|
||||
exports.getProfileAvatar = async (req, res) => {
|
||||
try {
|
||||
const userId = req.params.userId;
|
||||
const userId = req.params.userId || req.user.id;
|
||||
const profile = await Profile.findOne({ where: { user_id: userId } });
|
||||
if (!profile) {
|
||||
return res.status(404).json({ error: "Profile not found" });
|
||||
}
|
||||
|
||||
const uploadRecord = await Upload.findByPk(profile.profilePicture_id);
|
||||
if (!uploadRecord || uploadRecord.status !== "AVAILABLE" || (uploadRecord.visibility !== "PUBLIC" && uploadRecord.owner_id !== userId)) return res.status(404).json({ success: false, message: "Profile image not found" });
|
||||
|
||||
const fileUrl = await getSignedFileUrl(uploadRecord.file_path);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
userId: profile.userId,
|
||||
userId: profile.user_id,
|
||||
profilePictureUrl: fileUrl,
|
||||
},
|
||||
});
|
||||
@@ -58,7 +59,7 @@ exports.getProfileAvatar = async (req, res) => {
|
||||
// Get profile background image by user ID
|
||||
exports.getProfileBackgroundImage = async (req, res) => {
|
||||
try {
|
||||
const userId = req.params.userId;
|
||||
const userId = req.params.userId || req.user.id;
|
||||
const profile = await Profile.findOne({ where: { user_id: userId } });
|
||||
if (!profile) {
|
||||
return res
|
||||
@@ -67,13 +68,14 @@ exports.getProfileBackgroundImage = async (req, res) => {
|
||||
}
|
||||
|
||||
const uploadRecord = await Upload.findByPk(profile.backgroundImage_id);
|
||||
if (!uploadRecord || uploadRecord.status !== "AVAILABLE" || (uploadRecord.visibility !== "PUBLIC" && uploadRecord.owner_id !== userId)) return res.status(404).json({ success: false, message: "Profile background not found" });
|
||||
|
||||
const fileUrl = await getSignedFileUrl(uploadRecord.file_path);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
userId: profile.userId,
|
||||
userId: profile.user_id,
|
||||
backgroundImageUrl: fileUrl,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,131 +1,44 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// app/controllers/activity.controller.js
|
||||
|
||||
const { uploadToS3, getSignedFileUrl } = require("../utils/s3Upload.utill");
|
||||
const { log } = require("../utils/consoleLog.utill");
|
||||
|
||||
const db = require("../models");
|
||||
const Upload = db.Upload;
|
||||
const Assets = db.Assets;
|
||||
const storage = require("../services/storage/storage.service");
|
||||
const { validateUpload } = require("../services/storage/file-validation.service");
|
||||
|
||||
// Constants
|
||||
const MAX_IMAGE_SIZE = 3 * 1024 * 1024; // 3MB
|
||||
const MAX_PDF_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const canAccess = (user, upload) => upload.visibility === "PUBLIC" || upload.owner_id === user.id || user.accountType === "super_admin" || (user.accountType === "admin" && (user.permissions || []).includes("media.read"));
|
||||
|
||||
const isValidFileType = (mimetype) => {
|
||||
return mimetype.startsWith("image/") || mimetype === "application/pdf";
|
||||
};
|
||||
|
||||
const isValidFileSize = (mimetype, size) => {
|
||||
if (mimetype.startsWith("image/")) return size <= MAX_IMAGE_SIZE;
|
||||
if (mimetype === "application/pdf") return size <= MAX_PDF_SIZE;
|
||||
return false;
|
||||
};
|
||||
|
||||
exports.uploadFile = async (req, res) => {
|
||||
exports.uploadFile = async (req, res, next) => {
|
||||
let objectKey;
|
||||
try {
|
||||
// 1. Check file exists
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "No file uploaded",
|
||||
});
|
||||
}
|
||||
|
||||
const { mimetype, size, originalname } = req.file;
|
||||
|
||||
// 2. Validate file type
|
||||
if (!isValidFileType(mimetype)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Only images and PDFs are allowed",
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Validate file size
|
||||
if (!isValidFileSize(mimetype, size)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: mimetype.startsWith("image/")
|
||||
? "Image too large (max 1MB)"
|
||||
: "PDF too large (max 5MB)",
|
||||
});
|
||||
}
|
||||
|
||||
log("File validation passed:", {
|
||||
mimetype,
|
||||
size,
|
||||
originalname,
|
||||
use_for: req.body.use_for,
|
||||
});
|
||||
|
||||
// 4. Upload to S3
|
||||
const fileKey = await uploadToS3(req.file, req.body.use_for);
|
||||
|
||||
const fileUrl = await getSignedFileUrl(fileKey);
|
||||
|
||||
// 5. Save to DB
|
||||
const uploadData = {
|
||||
file_path: fileKey,
|
||||
file_type: mimetype,
|
||||
file_size: size,
|
||||
original_name: originalname,
|
||||
use_for: req.body.use_for || null,
|
||||
uploaded_by: req.user?.id || null,
|
||||
};
|
||||
|
||||
const newUpload = await Upload.create(uploadData);
|
||||
|
||||
newUpload.dataValues.file_url = fileUrl; // Add URL to response
|
||||
|
||||
// 6. Response
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
message: "File uploaded successfully",
|
||||
data: newUpload,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Upload Controller Error:", error);
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "Failed to upload file",
|
||||
error: process.env.NODE_ENV === "development" ? error.message : undefined,
|
||||
});
|
||||
}
|
||||
const details = validateUpload(req.file);
|
||||
const purpose = String(req.body.use_for || "generic").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 60);
|
||||
objectKey = storage.createObjectKey({ ownerId: req.user.id, mimeType: details.mimeType, purpose });
|
||||
await storage.uploadBuffer({ buffer: req.file.buffer, objectKey, mimeType: details.mimeType, checksum: details.checksum });
|
||||
let record;
|
||||
try {
|
||||
record = await db.Upload.create({ file_path: objectKey, file_type: details.mimeType, file_size: details.size, original_name: req.file.originalname, safe_name: details.safeName, checksum: details.checksum, use_for: purpose, uploaded_by: req.user.id, owner_type: "USER", owner_id: req.user.id, visibility: "PRIVATE", status: "AVAILABLE" });
|
||||
} catch (error) { await storage.deleteObject(objectKey).catch(() => undefined); throw error; }
|
||||
const expiresIn = Number(process.env.S3_SIGNED_URL_TTL_SECONDS || 900);
|
||||
return res.status(201).json({ success: true, data: { id: record.id, originalName: record.original_name, mimeType: record.file_type, size: record.file_size, purpose: record.use_for, status: record.status, url: await storage.createSignedDownloadUrl(objectKey, expiresIn), expiresIn } });
|
||||
} catch (error) { return next(error); }
|
||||
};
|
||||
|
||||
// Get Uploaded File URL
|
||||
exports.getFileUrl = async (req, res) => {
|
||||
exports.getFileUrl = async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const uploadRecord = await Upload.findByPk(id);
|
||||
|
||||
if (!uploadRecord) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "File not found",
|
||||
});
|
||||
}
|
||||
|
||||
const fileUrl = await getSignedFileUrl(uploadRecord.file_path);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: "File URL retrieved successfully",
|
||||
data: {
|
||||
id: uploadRecord.id,
|
||||
file_url: fileUrl,
|
||||
},
|
||||
});
|
||||
} catch (error) {}
|
||||
const upload = await db.Upload.findByPk(req.params.id);
|
||||
if (!upload || upload.status === "DELETED") return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "File not found" } });
|
||||
if (!canAccess(req.user, upload)) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } });
|
||||
if (upload.status !== "AVAILABLE") return res.status(409).json({ success: false, error: { code: "FILE_UNAVAILABLE", message: "File is not available" } });
|
||||
const expiresIn = Number(process.env.S3_SIGNED_URL_TTL_SECONDS || 900);
|
||||
return res.json({ success: true, data: { id: upload.id, url: await storage.createSignedDownloadUrl(upload.file_path, expiresIn), expiresIn } });
|
||||
} catch (error) { return next(error); }
|
||||
};
|
||||
|
||||
exports.deleteFile = async (req, res, next) => {
|
||||
try {
|
||||
const upload = await db.Upload.findByPk(req.params.id);
|
||||
if (!upload || upload.status === "DELETED") return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "File not found" } });
|
||||
const allowed = upload.owner_id === req.user.id || req.user.accountType === "super_admin" || (req.user.permissions || []).includes("media.delete");
|
||||
if (!allowed) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } });
|
||||
await upload.update({ status: "DELETED", deletedAt: new Date() });
|
||||
await storage.deleteObject(upload.file_path).catch(() => undefined);
|
||||
return res.status(204).end();
|
||||
} catch (error) { return next(error); }
|
||||
};
|
||||
|
||||
@@ -69,4 +69,8 @@ const registry = {
|
||||
|
||||
console.log("📋 Registry initialized with keys:", Object.keys(registry));
|
||||
|
||||
module.exports = registry;
|
||||
for (const [key, entry] of Object.entries(registry)) {
|
||||
if (typeof entry.pdfTemplate !== "function") delete registry[key];
|
||||
}
|
||||
|
||||
module.exports = registry;
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// app/middleware/docsSession.middleware.js
|
||||
|
||||
module.exports = (req, res, next) => {
|
||||
if (req.cookies && req.cookies.docsAuth === "true") {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: "Unauthorized: Please login to access docs",
|
||||
});
|
||||
};
|
||||
const { authenticate } = require("./auth.middleware");
|
||||
const { ACCOUNT_TYPES } = require("../constants/accountTypes");
|
||||
module.exports = (req, res, next) => authenticate(req, res, () => {
|
||||
if ([ACCOUNT_TYPES.ADMIN, ACCOUNT_TYPES.SUPER_ADMIN].includes(req.user.accountType)) return next();
|
||||
return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Administrative access required" } });
|
||||
});
|
||||
|
||||
@@ -16,23 +16,12 @@ const storage = multer.memoryStorage();
|
||||
|
||||
// File filter (only images + PDFs)
|
||||
const fileFilter = (req, file, cb) => {
|
||||
const allowedTypes = [
|
||||
"image/",
|
||||
"application/pdf",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx
|
||||
"application/vnd.ms-excel" // .xls
|
||||
];
|
||||
|
||||
const isValid =
|
||||
file.mimetype.startsWith("image/") ||
|
||||
file.mimetype === "application/pdf" ||
|
||||
file.mimetype === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ||
|
||||
file.mimetype === "application/vnd.ms-excel";
|
||||
const isValid = ["image/jpeg", "image/png", "image/webp", "application/pdf", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"].includes(file.mimetype);
|
||||
|
||||
if (isValid) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error("Only images, PDFs, and Excel files are allowed"), false);
|
||||
cb(new Error("Only JPEG, PNG, WebP, PDF, and XLSX files are allowed"), false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -41,7 +30,7 @@ const upload = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024, // max 5MB (global limit)
|
||||
fileSize: Number(process.env.S3_MAX_UPLOAD_BYTES || 5 * 1024 * 1024),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -61,4 +50,4 @@ module.exports = {
|
||||
uploadSingle,
|
||||
uploadMultiple,
|
||||
uploadFields,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,9 +18,10 @@ module.exports = (sequelize, DataTypes) => {
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
},
|
||||
event_id: { type: DataTypes.STRING, allowNull: true, unique: true },
|
||||
user_id: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
allowNull: true,
|
||||
},
|
||||
username: {
|
||||
type: DataTypes.STRING,
|
||||
@@ -45,13 +46,17 @@ module.exports = (sequelize, DataTypes) => {
|
||||
activity_date:{
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: false,
|
||||
}
|
||||
},
|
||||
target_type: DataTypes.STRING, target_id: DataTypes.STRING,
|
||||
ip_address: DataTypes.STRING, user_agent: DataTypes.STRING, request_id: DataTypes.STRING,
|
||||
metadata: DataTypes.JSON, occurred_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
|
||||
},
|
||||
{
|
||||
tableName: "UserActivity",
|
||||
timestamps: true,
|
||||
},
|
||||
);
|
||||
UserActivity.associate = (models) => UserActivity.belongsTo(models.User, { foreignKey: "user_id", as: "actor", constraints: false });
|
||||
|
||||
return UserActivity;
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ module.exports = (sequelize, DataTypes) => {
|
||||
"Document",
|
||||
{
|
||||
doc_id:{
|
||||
type: DataTypes.STRING,
|
||||
type: DataTypes.ENUM("DRAFT", "QUEUED", "PROCESSING", "COMPLETED", "FAILED"),
|
||||
primaryKey: true,
|
||||
},
|
||||
reference_no: {
|
||||
@@ -34,13 +34,21 @@ module.exports = (sequelize, DataTypes) => {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: "DRAFT",
|
||||
}
|
||||
},
|
||||
created_by: { type: DataTypes.STRING, allowNull: false },
|
||||
owner_type: { type: DataTypes.STRING, allowNull: false, defaultValue: "USER" },
|
||||
owner_id: { type: DataTypes.STRING, allowNull: false },
|
||||
storage_upload_id: { type: DataTypes.INTEGER, allowNull: true },
|
||||
job_id: { type: DataTypes.STRING, allowNull: true, unique: true },
|
||||
generated_at: DataTypes.DATE, failed_at: DataTypes.DATE, failure_code: DataTypes.STRING,
|
||||
version: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 1 },
|
||||
},
|
||||
{
|
||||
tableName: "Document",
|
||||
timestamps: true,
|
||||
},
|
||||
);
|
||||
document.associate = (models) => { document.belongsTo(models.User, { foreignKey: "created_by", as: "creator", constraints: false }); document.belongsTo(models.Upload, { foreignKey: "storage_upload_id", as: "storageUpload", constraints: false }); };
|
||||
|
||||
return document;
|
||||
};
|
||||
|
||||
@@ -20,8 +20,8 @@ module.exports = (sequelize, DataTypes) => {
|
||||
},
|
||||
doc_type_name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
defaultValue: "N/A"
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.JSON,
|
||||
|
||||
@@ -64,6 +64,7 @@ db.referenceNumber = require("./referenceNumbers/referenceNumber.model")(sequeli
|
||||
// Notifications
|
||||
db.notification = require("./notification/notification.model")(sequelize, DataTypes);
|
||||
db.userNotification = require("./notification/userNotification.model")(sequelize, DataTypes);
|
||||
db.NotificationDelivery = require("./notification/notificationDelivery.model")(sequelize, DataTypes);
|
||||
|
||||
/* Associations */
|
||||
Object.keys(db).forEach(model => {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
module.exports = (sequelize, DataTypes) => sequelize.define("NotificationDelivery", {
|
||||
id: { type: DataTypes.STRING, primaryKey: true },
|
||||
notificationId: { type: DataTypes.STRING, allowNull: true },
|
||||
userId: { type: DataTypes.STRING, allowNull: true },
|
||||
channel: { type: DataTypes.ENUM("EMAIL", "PUSH"), allowNull: false },
|
||||
recipient: { type: DataTypes.STRING, allowNull: false },
|
||||
templateKey: { type: DataTypes.STRING, allowNull: false },
|
||||
status: { type: DataTypes.ENUM("QUEUED", "PROCESSING", "SENT", "FAILED"), allowNull: false, defaultValue: "QUEUED" },
|
||||
attemptCount: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
|
||||
lastAttemptAt: DataTypes.DATE, sentAt: DataTypes.DATE, failedAt: DataTypes.DATE, failureCode: DataTypes.STRING,
|
||||
}, { tableName: "notification_deliveries", timestamps: true });
|
||||
@@ -41,11 +41,13 @@ module.exports = (sequelize, DataTypes) => {
|
||||
userNotification.associate = (models) => {
|
||||
userNotification.belongsTo(models.User, {
|
||||
foreignKey: "user_id",
|
||||
as: "user",
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
userNotification.belongsTo(models.notification, {
|
||||
foreignKey: "notification_id",
|
||||
as: "notification",
|
||||
constraints: false,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -43,12 +43,21 @@ module.exports = (sequelize, DataTypes) => {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
storage_provider: { type: DataTypes.STRING, allowNull: false, defaultValue: "S3" },
|
||||
safe_name: { type: DataTypes.STRING, allowNull: true },
|
||||
checksum: { type: DataTypes.STRING(64), allowNull: true },
|
||||
owner_type: { type: DataTypes.STRING, allowNull: false, defaultValue: "USER" },
|
||||
owner_id: { type: DataTypes.STRING, allowNull: false },
|
||||
visibility: { type: DataTypes.ENUM("PRIVATE", "PUBLIC"), allowNull: false, defaultValue: "PRIVATE" },
|
||||
status: { type: DataTypes.ENUM("PENDING", "AVAILABLE", "FAILED", "DELETED"), allowNull: false, defaultValue: "PENDING" },
|
||||
deletedAt: { type: DataTypes.DATE, allowNull: true },
|
||||
},
|
||||
{
|
||||
tableName: "uploads",
|
||||
timestamps: true,
|
||||
},
|
||||
);
|
||||
upload.associate = (models) => upload.belongsTo(models.User, { foreignKey: "uploaded_by", as: "uploader", constraints: false });
|
||||
|
||||
return upload;
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ const connection = require("../config/redisClient");
|
||||
|
||||
const activityQueue = new Queue("activity-queue", {
|
||||
connection,
|
||||
defaultJobOptions: { attempts: 3, backoff: { type: "exponential", delay: 1000 }, removeOnComplete: { age: 86400, count: 5000 }, removeOnFail: { age: 604800, count: 5000 } },
|
||||
});
|
||||
|
||||
module.exports = activityQueue;
|
||||
module.exports = activityQueue;
|
||||
|
||||
@@ -27,7 +27,7 @@ const documentQueue = new Queue("document-generation", {
|
||||
count: 1000,
|
||||
},
|
||||
|
||||
removeOnFail: false,
|
||||
removeOnFail: { age: 604800, count: 5000 },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
const { Queue } = require("bullmq");
|
||||
const connection = require("../config/redisClient");
|
||||
module.exports = new Queue("email-delivery", { connection, defaultJobOptions: { attempts: 5, backoff: { type: "exponential", delay: 2000 }, removeOnComplete: { age: 3600, count: 1000 }, removeOnFail: { age: 604800, count: 5000 } } });
|
||||
@@ -12,6 +12,6 @@
|
||||
const { Queue } = require("bullmq");
|
||||
const connection = require("../config/redisClient");
|
||||
|
||||
const logQueue = new Queue("logQueue", { connection });
|
||||
const logQueue = new Queue("logQueue", { connection, defaultJobOptions: { attempts: 2, backoff: { type: "fixed", delay: 1000 }, removeOnComplete: { age: 3600, count: 5000 }, removeOnFail: { age: 86400, count: 5000 } } });
|
||||
|
||||
module.exports = logQueue;
|
||||
module.exports = logQueue;
|
||||
|
||||
@@ -24,7 +24,7 @@ const PERMISSIONS = require("../constants/permissions");
|
||||
router.get(
|
||||
"/",
|
||||
authenticate,
|
||||
authorizedAccountType(["admin"]),
|
||||
checkPermission("audit.read", { custom: true }),
|
||||
activityController.getUserActivities,
|
||||
);
|
||||
|
||||
@@ -32,7 +32,7 @@ router.get(
|
||||
router.get(
|
||||
"/user/:userId",
|
||||
authenticate,
|
||||
authorizedAccountType(["admin"]),
|
||||
checkPermission("audit.read", { custom: true }),
|
||||
activityController.getActivitiesByUserId,
|
||||
);
|
||||
|
||||
|
||||
@@ -48,31 +48,8 @@ router.get("/view/:file", docsSession, (req, res) => {
|
||||
});
|
||||
|
||||
// Docs login (simple)
|
||||
router.post("/login", sensitiveLimiter, (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
router.post("/login", sensitiveLimiter, (_req, res) => res.status(410).json({ success: false, error: { code: "DEPRECATED", message: "Use an authenticated ADMIN or SUPER_ADMIN session" } }));
|
||||
|
||||
if (
|
||||
username === process.env.DOCS_USER &&
|
||||
password === process.env.DOCS_PASS
|
||||
) {
|
||||
res.cookie("docsAuth", "true", {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
});
|
||||
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: "Invalid credentials",
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/logout", (req, res) => {
|
||||
res.clearCookie("docsAuth");
|
||||
res.json({ success: true });
|
||||
});
|
||||
router.post("/logout", (_req, res) => res.status(410).json({ success: false, error: { code: "DEPRECATED", message: "Use the authentication logout endpoint" } }));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,94 +1,12 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// app/routes/document.routes.js
|
||||
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const generateDocumentController = require("../controllers/generateDocument.controller");
|
||||
const {
|
||||
authenticate,
|
||||
} = require("../middleware/auth.middleware");
|
||||
|
||||
const { authorizedAccountType, checkPermission } = require("../middleware/permission.middleware");
|
||||
const PERMISSIONS = require("../constants/permissions");
|
||||
|
||||
|
||||
// Get available document types
|
||||
router.get(
|
||||
"/types",
|
||||
authenticate,
|
||||
authorizedAccountType(["admin", "management", "team_head", "user"]),
|
||||
generateDocumentController.getAvailableDocumentTypes
|
||||
);
|
||||
|
||||
// Get saved documents
|
||||
router.post(
|
||||
"/saved",
|
||||
authenticate,
|
||||
authorizedAccountType(["admin", "management", "team_head", "user"]),
|
||||
generateDocumentController.getSavedDocuments
|
||||
);
|
||||
|
||||
// Generate Document (async - returns jobId immediately)
|
||||
router.post(
|
||||
"/generate",
|
||||
authenticate,
|
||||
authorizedAccountType(["admin", "management", "team_head", "user"]),
|
||||
generateDocumentController.generateDocument
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/draft",
|
||||
authenticate,
|
||||
authorizedAccountType(["admin", "management", "team_head", "user"]),
|
||||
generateDocumentController.generateDraftDocument
|
||||
);
|
||||
|
||||
// Generate Reference Number
|
||||
router.get(
|
||||
"/reference-number/:documentType",
|
||||
authenticate,
|
||||
authorizedAccountType(["admin", "management", "team_head", "user"]),
|
||||
generateDocumentController.generateReferenceNo
|
||||
);
|
||||
|
||||
// Get Job Status
|
||||
router.get(
|
||||
"/job/:jobId/status",
|
||||
authenticate,
|
||||
authorizedAccountType(["admin", "management", "team_head", "user"]),
|
||||
generateDocumentController.getJobStatus
|
||||
);
|
||||
|
||||
// Download Document
|
||||
router.get(
|
||||
"/download/:uuid",
|
||||
authenticate,
|
||||
authorizedAccountType(["admin", "management", "team_head", "user"]),
|
||||
generateDocumentController.downloadDocument
|
||||
);
|
||||
|
||||
// Cancel Job
|
||||
router.delete(
|
||||
"/job/:jobId",
|
||||
authenticate,
|
||||
authorizedAccountType(["admin", "management", "team_head", "user"]),
|
||||
generateDocumentController.cancelJob
|
||||
);
|
||||
|
||||
// Get document details
|
||||
router.get(
|
||||
"/:docId",
|
||||
authenticate,
|
||||
authorizedAccountType(["admin", "management", "team_head", "user"]),
|
||||
generateDocumentController.getDocumentData
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
const router = require("express").Router();
|
||||
const c = require("../controllers/generateDocument.controller");
|
||||
const { authenticate } = require("../middleware/auth.middleware");
|
||||
router.use(authenticate);
|
||||
router.get("/types", c.getAvailableDocumentTypes);
|
||||
router.get("/saved", c.getSavedDocuments); router.post("/saved", c.getSavedDocuments);
|
||||
router.post("/generate", c.generateDocument); router.post("/draft", c.generateDraftDocument);
|
||||
router.get("/reference-number/:documentType", c.generateReferenceNo);
|
||||
router.get("/jobs/:jobId", c.getJobStatus); router.get("/job/:jobId/status", c.getJobStatus);
|
||||
router.get("/:docId/download", c.downloadDocument); router.get("/download/:docId", c.downloadDocument);
|
||||
router.delete("/job/:jobId", c.cancelJob); router.get("/:docId", c.getDocumentData);
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,69 +1,11 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// app/routes/notification.routes.js
|
||||
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
|
||||
const notificationController = require("../controllers/notification.controller");
|
||||
|
||||
const {
|
||||
authenticate,
|
||||
} = require("../middleware/auth.middleware");
|
||||
|
||||
const {
|
||||
authorizedAccountType,
|
||||
checkPermission,
|
||||
} = require("../middleware/permission.middleware");
|
||||
|
||||
const PERMISSIONS = require("../constants/permissions");
|
||||
|
||||
|
||||
// Create notification
|
||||
router.post(
|
||||
"/",
|
||||
authenticate,
|
||||
authorizedAccountType(["admin", "management"]),
|
||||
// checkPermission(PERMISSIONS.NOTIFICATION_CREATE),
|
||||
notificationController.createNotification,
|
||||
);
|
||||
|
||||
|
||||
// Get all announcements
|
||||
router.get(
|
||||
"/announcements",
|
||||
authenticate,
|
||||
authorizedAccountType(["admin", "management", "team_head", "user"]),
|
||||
// checkPermission(PERMISSIONS.NOTIFICATION_VIEW),
|
||||
notificationController.getAnnouncements,
|
||||
);
|
||||
|
||||
|
||||
// Get notifications for a user
|
||||
router.get(
|
||||
"/user/:userId",
|
||||
authenticate,
|
||||
authorizedAccountType(["admin", "management", "team_head", "user"]),
|
||||
// checkPermission(PERMISSIONS.NOTIFICATION_VIEW),
|
||||
notificationController.getUserNotifications,
|
||||
);
|
||||
|
||||
|
||||
// Mark notification as read
|
||||
router.patch(
|
||||
"/user/:userId/notification/:notificationId/read",
|
||||
authenticate,
|
||||
authorizedAccountType(["admin", "management", "team_head", "user"]),
|
||||
// checkPermission(PERMISSIONS.NOTIFICATION_READ),
|
||||
notificationController.markAsRead,
|
||||
);
|
||||
|
||||
|
||||
module.exports = router;
|
||||
const router = require("express").Router();
|
||||
const controller = require("../controllers/notification.controller");
|
||||
const { authenticate } = require("../middleware/auth.middleware");
|
||||
const { checkPermission } = require("../middleware/permission.middleware");
|
||||
router.use(authenticate);
|
||||
router.post("/", checkPermission("notifications.manage", { custom: true }), controller.createNotification);
|
||||
router.get("/announcements", controller.getAnnouncements);
|
||||
router.get("/me", controller.getMyNotifications);
|
||||
router.patch("/read-all", controller.markAllAsRead);
|
||||
router.patch("/:notificationId/read", controller.markAsRead);
|
||||
module.exports = router;
|
||||
|
||||
@@ -36,6 +36,16 @@ router.post(
|
||||
authController.changePassword,
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/me/avatar",
|
||||
authenticate,
|
||||
profileController.getProfileAvatar,
|
||||
);
|
||||
router.get(
|
||||
"/me/background",
|
||||
authenticate,
|
||||
profileController.getProfileBackgroundImage,
|
||||
);
|
||||
router.get(
|
||||
"/avatar/:userId",
|
||||
authenticate,
|
||||
|
||||
@@ -24,7 +24,6 @@ const PERMISSIONS = require("../constants/permissions");
|
||||
router.post(
|
||||
"/",
|
||||
authenticate,
|
||||
authorizedAccountType(["admin", "staff"]),
|
||||
uploadSingle("file"),
|
||||
uploadController.uploadFile,
|
||||
);
|
||||
@@ -33,8 +32,8 @@ router.post(
|
||||
router.get(
|
||||
"/signed-url/:id",
|
||||
authenticate,
|
||||
authorizedAccountType(["admin", "staff"]),
|
||||
uploadController.getFileUrl,
|
||||
);
|
||||
router.delete("/:id", authenticate, uploadController.deleteFile);
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -19,7 +19,8 @@ const createActivityWorker = () => {
|
||||
const worker = new Worker(
|
||||
"activity-queue",
|
||||
async (job) => {
|
||||
await UserActivity.create(job.data);
|
||||
try { await UserActivity.create(job.data); }
|
||||
catch (error) { if (error.name === "SequelizeUniqueConstraintError" && job.data.event_id) return { duplicate: true }; throw error; }
|
||||
},
|
||||
{
|
||||
connection,
|
||||
|
||||
@@ -1,96 +1,26 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// app/workers/pdf.worker.js
|
||||
|
||||
const { Worker } = require("bullmq");
|
||||
|
||||
const redis = require("../config/redisClient");
|
||||
|
||||
const crypto = require("crypto");
|
||||
const connection = require("../config/redisClient");
|
||||
const db = require("../models");
|
||||
const { generateDocument } = require("../logic/documents");
|
||||
const storage = require("../services/storage/storage.service");
|
||||
|
||||
const { uploadToS3 } = require("../utils/s3Upload.utill");
|
||||
|
||||
module.exports = async function createDocumentWorker() {
|
||||
const documentWorker = new Worker(
|
||||
"document-generation",
|
||||
|
||||
async (job) => {
|
||||
try {
|
||||
const { document, documentType, data } = job.data;
|
||||
|
||||
console.log(
|
||||
`📄 Processing Job ${job.id}: document="${document}", documentType="${documentType}"`,
|
||||
);
|
||||
|
||||
const { fileBuffer, fileName, mimeType } = await generateDocument(
|
||||
document,
|
||||
documentType,
|
||||
data,
|
||||
);
|
||||
|
||||
/**
|
||||
* Store in S3 instead of local filesystem
|
||||
*/
|
||||
const s3Key = await uploadToS3(
|
||||
{
|
||||
originalname: fileName,
|
||||
buffer: fileBuffer,
|
||||
mimetype: mimeType,
|
||||
},
|
||||
"document",
|
||||
);
|
||||
|
||||
// Extract UUID from S3 key
|
||||
const documentId = s3Key
|
||||
.split("/")
|
||||
.pop()
|
||||
.replace(/\.[^/.]+$/, "");
|
||||
|
||||
console.log(`✅ Job ${job.id} completed: ${fileName} (${s3Key})`);
|
||||
|
||||
/**
|
||||
* Keep response compatible with existing flow
|
||||
*/
|
||||
return {
|
||||
fileName,
|
||||
mimeType,
|
||||
size: fileBuffer.length,
|
||||
s3Key,
|
||||
documentId,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`❌ Job ${job.id} failed:`, err.message);
|
||||
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
connection: redis,
|
||||
concurrency: 2,
|
||||
},
|
||||
);
|
||||
|
||||
documentWorker.on("error", (err) => {
|
||||
console.error("❌ Document Worker Error:", err);
|
||||
});
|
||||
|
||||
documentWorker.on("failed", (job, err) => {
|
||||
console.error(`❌ Job ${job?.id} failed after retries:`, err.message);
|
||||
});
|
||||
|
||||
documentWorker.on("completed", (job) => {
|
||||
console.log(`✅ Worker completed Job ${job.id}`);
|
||||
});
|
||||
|
||||
console.log("📄 Document Worker initialized");
|
||||
|
||||
return documentWorker;
|
||||
module.exports = function createDocumentWorker() {
|
||||
const worker = new Worker("document-generation", async (job) => {
|
||||
const { documentId, document, documentType, data } = job.data;
|
||||
const record = await db.Document.findByPk(documentId);
|
||||
if (!record) throw Object.assign(new Error("Document record not found"), { code: "DOCUMENT_NOT_FOUND" });
|
||||
if (record.status === "COMPLETED") return { documentId };
|
||||
await record.update({ status: "PROCESSING", failure_code: null, failed_at: null });
|
||||
const { fileBuffer, fileName, mimeType } = await generateDocument(document, documentType, data);
|
||||
const checksum = crypto.createHash("sha256").update(fileBuffer).digest("hex");
|
||||
const objectKey = storage.createObjectKey({ ownerId: record.owner_id, mimeType, purpose: "document" });
|
||||
await storage.uploadBuffer({ buffer: fileBuffer, objectKey, mimeType, checksum });
|
||||
let upload;
|
||||
try { upload = await db.Upload.create({ file_path: objectKey, file_type: mimeType, file_size: fileBuffer.length, original_name: fileName, safe_name: fileName, checksum, use_for: "document", uploaded_by: record.created_by, owner_type: record.owner_type, owner_id: record.owner_id, visibility: "PRIVATE", status: "AVAILABLE" }); await record.update({ status: "COMPLETED", storage_upload_id: upload.id, generated_at: new Date() }); }
|
||||
catch (error) { await storage.deleteObject(objectKey).catch(() => undefined); throw error; }
|
||||
return { documentId, uploadId: upload.id };
|
||||
}, { connection, concurrency: Number(process.env.DOCUMENT_QUEUE_CONCURRENCY || 2) });
|
||||
worker.on("failed", async (job, error) => { if (job && job.attemptsMade >= (job.opts.attempts || 1)) await db.Document.update({ status: "FAILED", failed_at: new Date(), failure_code: String(error.code || error.name || "GENERATION_FAILED").slice(0, 100) }, { where: { doc_id: job.data.documentId } }).catch(() => undefined); console.error("Document generation failed", { jobId: job?.id, code: error.code || error.name }); });
|
||||
return worker;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
const { Worker, UnrecoverableError } = require("bullmq");
|
||||
const connection = require("../config/redisClient");
|
||||
const db = require("../models");
|
||||
const { sendMail } = require("../utils/mail.util");
|
||||
const { templates } = require("../services/email/email.service");
|
||||
const permanent = (error) => ["EENVELOPE", "EMESSAGE"].includes(error.code) || /^5\d\d$/.test(String(error.responseCode || ""));
|
||||
module.exports = () => {
|
||||
const worker = new Worker("email-delivery", async (job) => {
|
||||
const { deliveryId, templateKey, recipient, variables } = job.data;
|
||||
const delivery = await db.NotificationDelivery.findByPk(deliveryId);
|
||||
await delivery?.update({ status: "PROCESSING", attemptCount: job.attemptsMade + 1, lastAttemptAt: new Date() });
|
||||
try { const result = await sendMail({ to: recipient, subject: templates[templateKey].subject, templateName: templateKey, templateVars: variables }); await delivery?.update({ status: "SENT", sentAt: new Date(), failureCode: null }); return { messageId: result.messageId }; }
|
||||
catch (error) { if (permanent(error)) throw new UnrecoverableError(error.code || "PERMANENT_REJECTION"); throw error; }
|
||||
}, { connection, concurrency: Number(process.env.EMAIL_QUEUE_CONCURRENCY || 5) });
|
||||
worker.on("failed", async (job, error) => { if (job && (job.attemptsMade >= (job.opts.attempts || 1) || error instanceof UnrecoverableError)) await db.NotificationDelivery.update({ status: "FAILED", failedAt: new Date(), failureCode: String(error.code || error.name || "DELIVERY_FAILED").slice(0, 100) }, { where: { id: job.data.deliveryId } }).catch(() => undefined); console.error("Email delivery failed", { jobId: job?.id, code: error.code || error.name }); });
|
||||
return worker;
|
||||
};
|
||||
@@ -22,7 +22,8 @@ const startWorkers = async () => {
|
||||
const createActivityWorker = require("./activity.worker");
|
||||
const createLogWorker = require("./log.worker");
|
||||
const createDocumentWorker = require("./document.worker");
|
||||
const workers = [createActivityWorker(), createLogWorker(), await createDocumentWorker()];
|
||||
const createEmailWorker = require("./email.worker");
|
||||
const workers = [createActivityWorker(), createLogWorker(), await createDocumentWorker(), createEmailWorker()];
|
||||
console.log("All workers started.");
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
@@ -25,7 +25,7 @@ const createLogWorker = () => {
|
||||
"logQueue",
|
||||
|
||||
async (job) => {
|
||||
const { message, timestamp } = job.data;
|
||||
const { message, timestamp, level = "info", service = "zumri-api", requestId, metadata } = job.data;
|
||||
|
||||
const logDate = new Date(timestamp)
|
||||
.toISOString()
|
||||
@@ -36,7 +36,7 @@ const createLogWorker = () => {
|
||||
`app-${logDate}.log`
|
||||
);
|
||||
|
||||
const logLine = `[${timestamp}] ${message}\n`;
|
||||
const logLine = `${JSON.stringify({ timestamp, level, service, requestId, message, metadata })}\n`;
|
||||
|
||||
await fs.promises.appendFile(
|
||||
logFilePath,
|
||||
@@ -70,4 +70,4 @@ const createLogWorker = () => {
|
||||
return worker;
|
||||
};
|
||||
|
||||
module.exports = createLogWorker;
|
||||
module.exports = createLogWorker;
|
||||
|
||||
Reference in New Issue
Block a user