feat: Implement Phase 2 cross-cutting services with email and notification enhancements

- Refactor email verification and password reset utilities to use new email service.
- Introduce email delivery queue and notification delivery model for better tracking.
- Enhance file validation and storage services for improved security and ownership management.
- Add cron job for cleaning inactive notifications with retention policy.
- Update document worker to handle document generation and storage more efficiently.
- Implement logging improvements in activity and log workers.
- Create comprehensive documentation for new API endpoints and services.
- Add unit tests for file validation and notification policies to ensure robustness.
This commit is contained in:
Sathira Sri Sathara
2026-09-03 14:22:34 +05:30
parent 9d3d431416
commit b6b345f245
54 changed files with 593 additions and 1248 deletions
+18 -414
View File
@@ -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); } };
+16 -134
View File
@@ -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); } };
+6 -4
View File
@@ -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,
},
});
+36 -123
View File
@@ -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); }
};