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