feat: private certificates/documents buckets with SeaweedFS support

Add Document CRUD, temp→bucket commit flow, SeaweedFS env aliases, and default temp bucket name to temp.
This commit is contained in:
2026-08-15 02:53:59 +03:30
parent 5c2fad4b44
commit 4603b45208
18 changed files with 585 additions and 112 deletions
@@ -19,6 +19,11 @@ exports.getAll = catchAsync(async (req, res, next) => {
return listResponse(res, 200, data, meta);
});
exports.getByUser = catchAsync(async (req, res) => {
const data = await certificateService.getCertificatesByUser(req.params.userId);
return successResponse(res, 200, 'User certificates retrieved successfully', data);
});
exports.update = catchAsync(async (req, res, next) => {
const certificate = await certificateService.updateCertificate(req.params.id, req.body);
return successResponse(res, 200, 'Certificate updated successfully', certificate);
@@ -23,15 +23,37 @@ const certificateSchema = new mongoose.Schema({
type: String,
trim: true
},
/** Permanent object key inside the certificates bucket */
fileKey: {
type: String,
required: true,
trim: true
},
/** Original uploaded filename */
fileName: {
type: String,
trim: true
},
/** Legacy alias kept in sync with fileName */
originalName: {
type: String,
trim: true
},
/** Public URL only when the certificates bucket is public; otherwise null */
fileUrl: {
type: String,
trim: true,
default: null
},
bucket: {
type: String,
trim: true,
default: 'certificates'
},
mimeType: {
type: String,
trim: true
},
issuedAt: {
type: Date,
default: Date.now
@@ -22,6 +22,7 @@ router.post('/user/upload', perm.requires(PERMISSIONS.FILES_UPLOAD), validateUpl
// Admin Scope
router.post('/admin/create', perm.requires(PERMISSIONS.CERTIFICATES_CREATE), validateCreateCertificate, certificateController.create);
router.get('/admin/get-all', perm.requires(PERMISSIONS.CERTIFICATES_READ), certificateController.getAll);
router.get('/admin/by-user/:userId', perm.requires(PERMISSIONS.CERTIFICATES_READ), certificateController.getByUser);
router.get('/admin/search', perm.requires(PERMISSIONS.CERTIFICATES_SEARCH), certificateController.search);
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.CERTIFICATES_READ), certificateController.getOne);
router.put('/admin/update/:id', perm.requires(PERMISSIONS.CERTIFICATES_UPDATE), validateUpdateCertificate, certificateController.update);
+57 -72
View File
@@ -4,26 +4,44 @@ const path = require('path');
const Certificate = require('./certificateModel');
const User = require('../users/userModel');
const AppError = require('../../utils/AppError');
const config = require('../../config/config');
const { commitTempFile, generatePresignedUrl, deleteFromBucket } = require('../../utils/s3Client');
const eventEmitter = require('../../events/eventEmitter');
const EVENT_NAMES = require('../../constants/eventNames');
const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination');
const BUCKET = 'certificates';
const withAccessUrl = async (cert) => {
const item = typeof cert.toObject === 'function' ? cert.toObject() : { ...cert };
item.fileUrl = item.fileUrl || null;
item.signedUrl = await generatePresignedUrl(item.fileKey, item.bucket || config.S3_CERTIFICATES_BUCKET);
item.presignedUrl = item.signedUrl;
item.url = item.fileUrl || item.signedUrl;
return item;
};
const createCertificate = async (data) => {
const user = await User.findById(data.user);
if (!user) throw new AppError('USER_NOT_FOUND');
if (!data.tempFileName) throw new AppError('FILE_REQUIRED');
// Move file from Temp bucket to Permanent Storage bucket
const targetKey = `certificates/cert-${Date.now()}-${path.basename(data.tempFileName)}`;
const { fileKey } = await commitTempFile(data.tempFileName, targetKey);
const safeName = path.basename(data.tempFileName);
const targetKey = `certificates/cert-${Date.now()}-${safeName}`;
const { fileKey, bucket, fileUrl } = await commitTempFile(data.tempFileName, targetKey, BUCKET);
const fileName = data.originalName || data.fileName || safeName;
const certificate = await Certificate.create({
user: data.user,
course: data.course || null,
title: data.title,
title: data.title || fileName,
issuer: data.issuer || '',
fileKey,
originalName: data.originalName || path.basename(data.tempFileName),
fileName,
originalName: fileName,
fileUrl: fileUrl || null,
bucket,
mimeType: data.mimeType || '',
isOfficial: data.isOfficial || false
});
@@ -32,27 +50,28 @@ const createCertificate = async (data) => {
eventEmitter.emit(EVENT_NAMES.CERTIFICATE_ISSUED, { certificateId: certificate._id, userId: user._id });
const resultObj = certificate.toObject();
resultObj.presignedUrl = await generatePresignedUrl(certificate.fileKey);
return resultObj;
return withAccessUrl(certificate);
};
const getCertificateById = async (id) => {
const certificate = await Certificate.findById(id)
.populate('user', 'name username nationalIdCode')
.populate('course', 'title type');
if (!certificate) {
throw new AppError('CERTIFICATE_NOT_FOUND');
}
if (!certificate) throw new AppError('CERTIFICATE_NOT_FOUND');
return withAccessUrl(certificate);
};
const resultObj = certificate.toObject();
resultObj.presignedUrl = await generatePresignedUrl(certificate.fileKey);
return resultObj;
const getCertificatesByUser = async (userId) => {
const certificates = await Certificate.find({ user: userId })
.populate('course', 'title type')
.sort({ createdAt: -1 });
return Promise.all(certificates.map(withAccessUrl));
};
const getAllCertificates = async (queryParams) => {
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams);
const filter = buildFilterQuery(queryParams, ['title', 'issuer']);
if (queryParams.userId) filter.user = queryParams.userId;
const [certificates, totalCount] = await Promise.all([
Certificate.find(filter)
@@ -64,30 +83,28 @@ const getAllCertificates = async (queryParams) => {
Certificate.countDocuments(filter)
]);
const listWithUrls = await Promise.all(
certificates.map(async (cert) => {
const item = cert.toObject();
item.presignedUrl = await generatePresignedUrl(cert.fileKey);
return item;
})
);
const meta = calculateMeta(totalCount, page, limit);
return { data: listWithUrls, meta };
const data = await Promise.all(certificates.map(withAccessUrl));
return { data, meta: calculateMeta(totalCount, page, limit) };
};
const updateCertificate = async (id, updateData) => {
const certificate = await Certificate.findById(id);
if (!certificate) {
throw new AppError('CERTIFICATE_NOT_FOUND');
}
if (!certificate) throw new AppError('CERTIFICATE_NOT_FOUND');
if (updateData.tempFileName) {
const targetKey = `certificates/cert-${Date.now()}-${path.basename(updateData.tempFileName)}`;
const { fileKey } = await commitTempFile(updateData.tempFileName, targetKey);
// Delete old file from storage bucket
await deleteFromBucket(certificate.fileKey);
const safeName = path.basename(updateData.tempFileName);
const targetKey = `certificates/cert-${Date.now()}-${safeName}`;
const { fileKey, bucket, fileUrl } = await commitTempFile(updateData.tempFileName, targetKey, BUCKET);
await deleteFromBucket(certificate.fileKey, certificate.bucket || config.S3_CERTIFICATES_BUCKET);
certificate.fileKey = fileKey;
certificate.bucket = bucket;
certificate.fileUrl = fileUrl || null;
const fileName = updateData.originalName || updateData.fileName;
if (fileName) {
certificate.fileName = fileName;
certificate.originalName = fileName;
}
if (updateData.mimeType) certificate.mimeType = updateData.mimeType;
}
if (updateData.title) certificate.title = updateData.title;
@@ -95,53 +112,28 @@ const updateCertificate = async (id, updateData) => {
if (updateData.isOfficial !== undefined) certificate.isOfficial = updateData.isOfficial;
await certificate.save();
const resultObj = certificate.toObject();
resultObj.presignedUrl = await generatePresignedUrl(certificate.fileKey);
return resultObj;
return withAccessUrl(certificate);
};
const deleteCertificate = async (id) => {
const certificate = await Certificate.findById(id);
if (!certificate) {
throw new AppError('CERTIFICATE_NOT_FOUND');
}
if (!certificate) throw new AppError('CERTIFICATE_NOT_FOUND');
await deleteFromBucket(certificate.fileKey);
await deleteFromBucket(certificate.fileKey, certificate.bucket || config.S3_CERTIFICATES_BUCKET);
await User.findByIdAndUpdate(certificate.user, { $pull: { certificates: certificate._id } });
await Certificate.findByIdAndDelete(id);
return null;
};
const searchCertificates = async (queryParams) => {
return getAllCertificates(queryParams);
};
const searchCertificates = async (queryParams) => getAllCertificates(queryParams);
const uploadUserCertificate = async (userId, data) => {
const user = await User.findById(userId);
if (!user) throw new AppError('USER_NOT_FOUND');
const targetKey = `certificates/cert-${Date.now()}-${path.basename(data.tempFileName)}`;
const { fileKey } = await commitTempFile(data.tempFileName, targetKey);
const certificate = await Certificate.create({
return createCertificate({
...data,
user: userId,
course: data.course || null,
title: data.title,
issuer: data.issuer || 'Self-Uploaded',
fileKey,
originalName: data.originalName || path.basename(data.tempFileName),
isOfficial: false
});
user.certificates.push(certificate._id);
await user.save();
eventEmitter.emit(EVENT_NAMES.CERTIFICATE_UPLOADED, { certificateId: certificate._id, userId });
const resultObj = certificate.toObject();
resultObj.presignedUrl = await generatePresignedUrl(certificate.fileKey);
return resultObj;
};
const getMyCertificates = async (userId, queryParams) => {
@@ -157,21 +149,14 @@ const getMyCertificates = async (userId, queryParams) => {
Certificate.countDocuments(filter)
]);
const listWithUrls = await Promise.all(
certificates.map(async (cert) => {
const item = cert.toObject();
item.presignedUrl = await generatePresignedUrl(cert.fileKey);
return item;
})
);
const meta = calculateMeta(totalCount, page, limit);
return { data: listWithUrls, meta };
const data = await Promise.all(certificates.map(withAccessUrl));
return { data, meta: calculateMeta(totalCount, page, limit) };
};
module.exports = {
createCertificate,
getCertificateById,
getCertificatesByUser,
getAllCertificates,
updateCertificate,
deleteCertificate,
@@ -0,0 +1,40 @@
// /components/documents/documentController.js
const catchAsync = require('../../utils/catchAsync');
const documentService = require('./documentService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
exports.create = catchAsync(async (req, res) => {
const document = await documentService.createDocument(req.body, req.user?._id);
return successResponse(res, 201, 'Document created successfully', document);
});
exports.getOne = catchAsync(async (req, res) => {
const document = await documentService.getDocumentById(req.params.id);
return successResponse(res, 200, 'Document retrieved successfully', document);
});
exports.getAll = catchAsync(async (req, res) => {
const { data, meta } = await documentService.getAllDocuments(req.query);
return listResponse(res, 200, data, meta);
});
exports.getByUser = catchAsync(async (req, res) => {
const data = await documentService.getDocumentsByUser(req.params.userId);
return successResponse(res, 200, 'User documents retrieved successfully', data);
});
exports.update = catchAsync(async (req, res) => {
const document = await documentService.updateDocument(req.params.id, req.body);
return successResponse(res, 200, 'Document updated successfully', document);
});
exports.delete = catchAsync(async (req, res) => {
await documentService.deleteDocument(req.params.id);
return successResponse(res, 200, 'Document deleted successfully');
});
exports.getMyDocuments = catchAsync(async (req, res) => {
const { data, meta } = await documentService.getMyDocuments(req.user._id, req.query);
return listResponse(res, 200, data, meta);
});
+56
View File
@@ -0,0 +1,56 @@
// /components/documents/documentModel.js
const mongoose = require('mongoose');
const documentSchema = new mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
title: {
type: String,
required: true,
trim: true
},
description: {
type: String,
trim: true,
default: ''
},
/** Permanent object key inside the documents bucket */
fileKey: {
type: String,
required: true,
trim: true
},
/** Original uploaded filename */
fileName: {
type: String,
trim: true
},
/** Public URL only when the documents bucket is public; otherwise null */
fileUrl: {
type: String,
trim: true,
default: null
},
bucket: {
type: String,
trim: true,
default: 'documents'
},
mimeType: {
type: String,
trim: true
},
uploadedBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
}
}, {
timestamps: true
});
module.exports = mongoose.model('Document', documentSchema);
+22
View File
@@ -0,0 +1,22 @@
// /components/documents/documentRoutes.js
const express = require('express');
const documentController = require('./documentController');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
router.use(authMiddleware);
router.get('/user/my-documents', perm.requires(PERMISSIONS.DOCUMENTS_READ), documentController.getMyDocuments);
router.post('/admin/create', perm.requires(PERMISSIONS.DOCUMENTS_CREATE), documentController.create);
router.get('/admin/get-all', perm.requires(PERMISSIONS.DOCUMENTS_READ), documentController.getAll);
router.get('/admin/by-user/:userId', perm.requires(PERMISSIONS.DOCUMENTS_READ), documentController.getByUser);
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.DOCUMENTS_READ), documentController.getOne);
router.put('/admin/update/:id', perm.requires(PERMISSIONS.DOCUMENTS_UPDATE), documentController.update);
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.DOCUMENTS_DELETE), documentController.delete);
module.exports = router;
+140
View File
@@ -0,0 +1,140 @@
// /components/documents/documentService.js
const path = require('path');
const Document = require('./documentModel');
const User = require('../users/userModel');
const AppError = require('../../utils/AppError');
const config = require('../../config/config');
const {
commitTempFile,
generatePresignedUrl,
deleteFromBucket
} = require('../../utils/s3Client');
const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination');
const BUCKET = 'documents';
const withAccessUrl = async (doc) => {
const item = typeof doc.toObject === 'function' ? doc.toObject() : { ...doc };
item.fileUrl = item.fileUrl || null;
item.signedUrl = await generatePresignedUrl(item.fileKey, item.bucket || config.S3_DOCUMENTS_BUCKET);
item.presignedUrl = item.signedUrl;
item.url = item.fileUrl || item.signedUrl;
return item;
};
const createDocument = async (data, uploadedBy = null) => {
const user = await User.findById(data.user);
if (!user) throw new AppError('USER_NOT_FOUND');
if (!data.tempFileName) throw new AppError('FILE_REQUIRED');
const safeName = path.basename(data.tempFileName);
const targetKey = `documents/doc-${Date.now()}-${safeName}`;
const { fileKey, bucket, fileUrl } = await commitTempFile(data.tempFileName, targetKey, BUCKET);
const document = await Document.create({
user: data.user,
title: data.title || data.originalName || safeName,
description: data.description || '',
fileKey,
fileName: data.originalName || data.fileName || safeName,
fileUrl: fileUrl || null,
bucket,
mimeType: data.mimeType || '',
uploadedBy: uploadedBy || null
});
if (Array.isArray(user.documents)) {
user.documents.push(document._id);
await user.save();
}
return withAccessUrl(document);
};
const getDocumentById = async (id) => {
const document = await Document.findById(id).populate('user', 'name username nationalIdCode');
if (!document) throw new AppError('DOCUMENT_NOT_FOUND');
return withAccessUrl(document);
};
const getDocumentsByUser = async (userId) => {
const documents = await Document.find({ user: userId }).sort({ createdAt: -1 });
return Promise.all(documents.map(withAccessUrl));
};
const getAllDocuments = async (queryParams) => {
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams);
const filter = buildFilterQuery(queryParams, ['title', 'fileName', 'description']);
if (queryParams.userId) filter.user = queryParams.userId;
const [documents, totalCount] = await Promise.all([
Document.find(filter)
.populate('user', 'name username')
.sort(sort)
.skip(skip)
.limit(limit),
Document.countDocuments(filter)
]);
const data = await Promise.all(documents.map(withAccessUrl));
return { data, meta: calculateMeta(totalCount, page, limit) };
};
const updateDocument = async (id, updateData) => {
const document = await Document.findById(id);
if (!document) throw new AppError('DOCUMENT_NOT_FOUND');
if (updateData.tempFileName) {
const safeName = path.basename(updateData.tempFileName);
const targetKey = `documents/doc-${Date.now()}-${safeName}`;
const { fileKey, bucket, fileUrl } = await commitTempFile(updateData.tempFileName, targetKey, BUCKET);
await deleteFromBucket(document.fileKey, document.bucket || config.S3_DOCUMENTS_BUCKET);
document.fileKey = fileKey;
document.bucket = bucket;
document.fileUrl = fileUrl || null;
if (updateData.originalName || updateData.fileName) {
document.fileName = updateData.originalName || updateData.fileName;
}
if (updateData.mimeType) document.mimeType = updateData.mimeType;
}
if (updateData.title) document.title = updateData.title;
if (updateData.description !== undefined) document.description = updateData.description;
await document.save();
return withAccessUrl(document);
};
const deleteDocument = async (id) => {
const document = await Document.findById(id);
if (!document) throw new AppError('DOCUMENT_NOT_FOUND');
await deleteFromBucket(document.fileKey, document.bucket || config.S3_DOCUMENTS_BUCKET);
await User.findByIdAndUpdate(document.user, { $pull: { documents: document._id } });
await Document.findByIdAndDelete(id);
return null;
};
const getMyDocuments = async (userId, queryParams = {}) => {
const filter = { user: userId, ...buildFilterQuery(queryParams, ['title', 'fileName']) };
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams);
const [documents, totalCount] = await Promise.all([
Document.find(filter).sort(sort).skip(skip).limit(limit),
Document.countDocuments(filter)
]);
const data = await Promise.all(documents.map(withAccessUrl));
return { data, meta: calculateMeta(totalCount, page, limit) };
};
module.exports = {
createDocument,
getDocumentById,
getDocumentsByUser,
getAllDocuments,
updateDocument,
deleteDocument,
getMyDocuments
};
+11 -6
View File
@@ -1,6 +1,7 @@
// /components/files/fileService.js
const path = require('path');
const config = require('../../config/config');
const {
uploadToTempBucket,
commitTempFile,
@@ -22,7 +23,8 @@ const uploadTempFile = async (fileBuffer, originalName, mimeType) => {
return {
tempFileName: result.tempFileName,
originalName,
mimeType
mimeType,
bucket: result.bucket
};
};
@@ -31,20 +33,23 @@ const getPresignedUrl = async (filename, bucket = null) => {
throw new AppError('VALIDATION_FAILED', null, 'Filename is required');
}
const signedUrl = await generatePresignedUrl(filename, bucket || undefined);
const targetBucket = bucket || config.S3_TEMP_BUCKET;
const signedUrl = await generatePresignedUrl(filename, targetBucket);
return {
filename,
bucket: targetBucket,
presignedUrl: signedUrl,
expiresInSeconds: 900
signedUrl,
expiresInSeconds: config.SIGNED_URL_EXPIRES_IN
};
};
const commitFile = async (tempFileName, targetFileName = null) => {
return commitTempFile(tempFileName, targetFileName);
const commitFile = async (tempFileName, targetFileName = null, targetBucket = 'certificates') => {
return commitTempFile(tempFileName, targetFileName, targetBucket);
};
const deleteFile = async (filename, bucket = null) => {
return deleteFromBucket(filename, bucket);
return deleteFromBucket(filename, bucket || config.S3_TEMP_BUCKET);
};
module.exports = {
+4
View File
@@ -102,6 +102,10 @@ const userSchema = new mongoose.Schema({
type: mongoose.Schema.Types.ObjectId,
ref: 'Certificate'
}],
documents: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Document'
}],
refreshTokens: [refreshTokenSchema],
isActive: {
type: Boolean,