@ideal-photography/shared
Version:
Shared MongoDB and utility logic for Ideal Photography PWAs: users, products, services, bookings, orders/cart, galleries, reviews, notifications, campaigns, settings, audit logs, minimart items/orders, and push notification subscriptions.
818 lines (717 loc) • 25.7 kB
JavaScript
import { v2 as cloudinary } from 'cloudinary';
// Use environment variables directly since config is in different servers
const CLOUDINARY_CLOUD_NAME = process.env.CLOUDINARY_CLOUD_NAME;
const CLOUDINARY_API_KEY = process.env.CLOUDINARY_API_KEY;
const CLOUDINARY_API_SECRET = process.env.CLOUDINARY_API_SECRET;
// Configure Cloudinary
cloudinary.config({
cloud_name: CLOUDINARY_CLOUD_NAME,
api_key: CLOUDINARY_API_KEY,
api_secret: CLOUDINARY_API_SECRET,
secure: true
});
/**
* Upload file to Cloudinary with optimization
* @param {Buffer|string} file - File buffer or file path
* @param {Object} options - Upload options
* @returns {Promise<Object>} Cloudinary upload result
*/
export const uploadToCloudinary = async (file, options = {}) => {
const {
folder = 'uploads',
public_id,
resource_type = 'auto',
transformation,
tags = [],
overwrite = false,
unique_filename = true,
use_filename = false
} = options;
try {
const uploadOptions = {
folder,
resource_type,
overwrite,
unique_filename,
use_filename,
tags: Array.isArray(tags) ? tags : [tags].filter(Boolean)
};
if (public_id) {
uploadOptions.public_id = public_id;
}
if (transformation) {
uploadOptions.transformation = transformation;
}
let uploadResult;
if (Buffer.isBuffer(file)) {
// Upload from buffer
uploadResult = await new Promise((resolve, reject) => {
cloudinary.uploader.upload_stream(
uploadOptions,
(error, result) => {
if (error) reject(error);
else resolve(result);
}
).end(file);
});
} else {
// Upload from file path or URL
uploadResult = await cloudinary.uploader.upload(file, uploadOptions);
}
return uploadResult;
} catch (error) {
console.error('Cloudinary upload error:', error);
throw new Error(`Failed to upload to Cloudinary: ${error.message}`);
}
};
/**
* Delete file from Cloudinary
* @param {string} publicId - Cloudinary public ID
* @param {string} resourceType - Resource type (image, video, raw)
* @returns {Promise<Object>} Deletion result
*/
export const deleteFromCloudinary = async (publicId, resourceType = 'image') => {
try {
const result = await cloudinary.uploader.destroy(publicId, {
resource_type: resourceType
});
return result;
} catch (error) {
console.error('Cloudinary delete error:', error);
throw new Error(`Failed to delete from Cloudinary: ${error.message}`);
}
};
/**
* Generate transformation URL
* @param {string} publicId - Cloudinary public ID
* @param {Object} transformation - Transformation options
* @returns {string} Transformed URL
*/
export const generateTransformationUrl = (publicId, transformation = {}) => {
try {
return cloudinary.url(publicId, {
...transformation,
secure: true
});
} catch (error) {
console.error('Cloudinary URL generation error:', error);
return null;
}
};
/**
* Get optimized image URL
* @param {string} publicId - Cloudinary public ID
* @param {Object} options - Optimization options
* @returns {string} Optimized URL
*/
export const getOptimizedImageUrl = (publicId, options = {}) => {
const {
width = 'auto',
height = 'auto',
crop = 'scale',
quality = 'auto',
format = 'auto'
} = options;
return generateTransformationUrl(publicId, {
width,
height,
crop,
quality,
fetch_format: format
});
};
/**
* Generate image variants for different use cases
* @param {string} publicId - Cloudinary public ID
* @returns {Object} Object with different image variants
*/
export const generateImageVariants = (publicId) => {
return {
thumbnail: generateTransformationUrl(publicId, {
width: 150,
height: 150,
crop: 'thumb',
gravity: 'face'
}),
small: generateTransformationUrl(publicId, {
width: 300,
crop: 'scale',
quality: 'auto',
fetch_format: 'auto'
}),
medium: generateTransformationUrl(publicId, {
width: 600,
crop: 'scale',
quality: 'auto',
fetch_format: 'auto'
}),
large: generateTransformationUrl(publicId, {
width: 1200,
crop: 'scale',
quality: 'auto',
fetch_format: 'auto'
}),
optimized: generateTransformationUrl(publicId, {
quality: 'auto',
fetch_format: 'auto'
})
};
};
/**
* Upload multiple files
* @param {Array} files - Array of files to upload
* @param {Object} options - Upload options
* @returns {Promise<Array>} Array of upload results
*/
export const uploadMultipleFiles = async (files, options = {}) => {
const uploadPromises = files.map(file => uploadToCloudinary(file, options));
try {
const results = await Promise.allSettled(uploadPromises);
return results.map((result, index) => ({
index,
success: result.status === 'fulfilled',
data: result.status === 'fulfilled' ? result.value : null,
error: result.status === 'rejected' ? result.reason.message : null
}));
} catch (error) {
throw new Error(`Failed to upload multiple files: ${error.message}`);
}
};
/**
* Get file info from Cloudinary
* @param {string} publicId - Cloudinary public ID
* @param {string} resourceType - Resource type
* @returns {Promise<Object>} File information
*/
export const getFileInfo = async (publicId, resourceType = 'image') => {
try {
const result = await cloudinary.api.resource(publicId, {
resource_type: resourceType
});
return result;
} catch (error) {
console.error('Cloudinary get file info error:', error);
throw new Error(`Failed to get file info: ${error.message}`);
}
};
/**
* Search files in Cloudinary
* @param {Object} searchOptions - Search parameters
* @returns {Promise<Object>} Search results
*/
export const searchFiles = async (searchOptions = {}) => {
const {
expression = '',
sort_by = [['created_at', 'desc']],
max_results = 50,
next_cursor
} = searchOptions;
try {
const searchParams = {
expression,
sort_by,
max_results
};
if (next_cursor) {
searchParams.next_cursor = next_cursor;
}
const result = await cloudinary.search.expression(expression)
.sort_by(...sort_by)
.max_results(max_results)
.execute();
return result;
} catch (error) {
console.error('Cloudinary search error:', error);
throw new Error(`Failed to search files: ${error.message}`);
}
};
/**
* Get folder contents
* @param {string} folderPath - Folder path
* @param {Object} options - Options
* @returns {Promise<Object>} Folder contents
*/
export const getFolderContents = async (folderPath, options = {}) => {
const {
resource_type = 'image',
type = 'upload',
max_results = 50,
next_cursor
} = options;
try {
const searchParams = {
resource_type,
type,
prefix: folderPath,
max_results
};
if (next_cursor) {
searchParams.next_cursor = next_cursor;
}
const result = await cloudinary.api.resources(searchParams);
return result;
} catch (error) {
console.error('Cloudinary folder contents error:', error);
throw new Error(`Failed to get folder contents: ${error.message}`);
}
};
/**
* Create folder
* @param {string} folderPath - Folder path to create
* @returns {Promise<Object>} Creation result
*/
export const createFolder = async (folderPath) => {
try {
const result = await cloudinary.api.create_folder(folderPath);
return result;
} catch (error) {
console.error('Cloudinary create folder error:', error);
throw new Error(`Failed to create folder: ${error.message}`);
}
};
/**
* Validate file before upload
* @param {Object} file - File object
* @param {Object} options - Validation options
* @returns {Object} Validation result
*/
export const validateFile = (file, options = {}) => {
const {
maxSize = 10 * 1024 * 1024, // 10MB default
allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp']
} = options;
const errors = [];
// Check file size
if (file.size > maxSize) {
errors.push(`File size (${(file.size / 1024 / 1024).toFixed(2)}MB) exceeds maximum allowed size (${maxSize / 1024 / 1024}MB)`);
}
// Check MIME type
if (file.mimetype && !allowedTypes.includes(file.mimetype)) {
errors.push(`File type '${file.mimetype}' is not allowed. Allowed types: ${allowedTypes.join(', ')}`);
}
// Check file extension
if (file.originalname) {
const extension = file.originalname.split('.').pop().toLowerCase();
if (!allowedExtensions.includes(extension)) {
errors.push(`File extension '.${extension}' is not allowed. Allowed extensions: ${allowedExtensions.join(', ')}`);
}
}
return {
isValid: errors.length === 0,
errors
};
};
/**
* Upload multiple product images with consistent naming and optimization
* @param {Array} imageFiles - Array of image files or buffers
* @param {string} productId - Product ID for folder organization
* @param {Object} options - Upload options
* @returns {Promise<Array>} Array of uploaded image objects with URLs
*/
export const uploadProductImages = async (imageFiles, productId, options = {}) => {
const {
folder = 'equipment',
maxImages = 3,
generateVariants = true,
tags = []
} = options;
if (!Array.isArray(imageFiles) || imageFiles.length === 0) {
throw new Error('imageFiles must be a non-empty array');
}
if (imageFiles.length > maxImages) {
throw new Error(`Maximum ${maxImages} images allowed`);
}
if (!productId) {
throw new Error('productId is required');
}
const uploadPromises = imageFiles.map(async (file, index) => {
try {
// Validate file before upload
const validation = validateFile(file, {
maxSize: 5 * 1024 * 1024, // 5MB for product images
allowedTypes: ['image/jpeg', 'image/png', 'image/webp']
});
if (!validation.isValid) {
throw new Error(`File ${index + 1} validation failed: ${validation.errors.join(', ')}`);
}
const uploadOptions = {
folder: `${folder}/${productId}`,
public_id: `${productId}_image_${index + 1}_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`,
transformation: [
{ quality: 'auto:good' },
{ fetch_format: 'auto' },
{ width: 1200, height: 1200, crop: 'limit' }
],
tags: [...tags, 'product', 'equipment', productId],
overwrite: false,
unique_filename: false,
use_filename: false
};
const uploadResult = await uploadToCloudinary(file, uploadOptions);
const imageData = {
publicId: uploadResult.public_id,
url: uploadResult.secure_url,
thumbnailUrl: generateTransformationUrl(uploadResult.public_id, {
width: 300,
height: 300,
crop: 'thumb',
gravity: 'auto',
quality: 'auto',
fetch_format: 'auto'
}),
mediumUrl: generateTransformationUrl(uploadResult.public_id, {
width: 600,
height: 600,
crop: 'limit',
quality: 'auto',
fetch_format: 'auto'
}),
largeUrl: uploadResult.secure_url,
width: uploadResult.width,
height: uploadResult.height,
format: uploadResult.format,
bytes: uploadResult.bytes,
order: index,
uploadedAt: new Date().toISOString()
};
if (generateVariants) {
const variants = generateImageVariants(uploadResult.public_id);
imageData.variants = variants;
}
return imageData;
} catch (error) {
console.error(`Error uploading image ${index + 1}:`, error);
return {
error: error.message,
index,
success: false
};
}
});
const results = await Promise.allSettled(uploadPromises);
const successfulUploads = [];
const failedUploads = [];
results.forEach((result, index) => {
if (result.status === 'fulfilled' && !result.value.error) {
successfulUploads.push(result.value);
} else {
failedUploads.push({
index,
error: result.status === 'rejected' ? result.reason.message : result.value.error
});
}
});
return {
successful: successfulUploads,
failed: failedUploads,
totalUploaded: successfulUploads.length,
totalFailed: failedUploads.length
};
};
/**
* Delete multiple product images from Cloudinary
* @param {Array} publicIds - Array of Cloudinary public IDs
* @param {Object} options - Delete options
* @returns {Promise<Object>} Deletion results
*/
export const deleteProductImages = async (publicIds, options = {}) => {
const { resourceType = 'image' } = options;
if (!Array.isArray(publicIds) || publicIds.length === 0) {
return { successful: [], failed: [], totalDeleted: 0, totalFailed: 0 };
}
const deletePromises = publicIds.map(async (publicId) => {
try {
const result = await deleteFromCloudinary(publicId, resourceType);
return {
publicId,
result,
success: result.result === 'ok'
};
} catch (error) {
console.error(`Error deleting image ${publicId}:`, error);
return {
publicId,
error: error.message,
success: false
};
}
});
const results = await Promise.allSettled(deletePromises);
const successful = [];
const failed = [];
results.forEach((result) => {
if (result.status === 'fulfilled') {
if (result.value.success) {
successful.push(result.value);
} else {
failed.push(result.value);
}
} else {
failed.push({
error: result.reason.message,
success: false
});
}
});
return {
successful,
failed,
totalDeleted: successful.length,
totalFailed: failed.length
};
};
/**
* Update product images - handles adding new images and removing old ones
* @param {Array} currentImages - Current product images
* @param {Array} newImageFiles - New image files to upload
* @param {string} productId - Product ID
* @param {Array} imagesToDelete - Public IDs of images to delete
* @param {Object} options - Update options
* @returns {Promise<Object>} Updated images array and operation results
*/
export const updateProductImages = async (currentImages = [], newImageFiles = [], productId, imagesToDelete = [], options = {}) => {
const { maxImages = 3 } = options;
if (!productId) {
throw new Error('productId is required');
}
const results = {
uploadResults: null,
deleteResults: null,
updatedImages: [...currentImages],
errors: []
};
try {
// First, delete images that need to be removed
if (imagesToDelete.length > 0) {
console.log(`Deleting ${imagesToDelete.length} images from Cloudinary...`);
results.deleteResults = await deleteProductImages(imagesToDelete);
if (results.deleteResults.totalFailed > 0) {
console.warn(`Failed to delete ${results.deleteResults.totalFailed} images`);
results.errors.push(`Failed to delete ${results.deleteResults.totalFailed} images from Cloudinary`);
}
// Remove deleted images from current images array
results.updatedImages = currentImages.filter(img =>
!imagesToDelete.includes(img.publicId)
);
}
// Check if we can add new images without exceeding the limit
const availableSlots = maxImages - results.updatedImages.length;
if (newImageFiles.length > availableSlots) {
throw new Error(`Cannot add ${newImageFiles.length} images. Only ${availableSlots} slots available (max ${maxImages} images per product)`);
}
// Upload new images if any
if (newImageFiles.length > 0) {
console.log(`Uploading ${newImageFiles.length} new images...`);
results.uploadResults = await uploadProductImages(newImageFiles, productId, options);
if (results.uploadResults.totalFailed > 0) {
console.warn(`Failed to upload ${results.uploadResults.totalFailed} images`);
results.errors.push(`Failed to upload ${results.uploadResults.totalFailed} images`);
}
// Add successfully uploaded images to the updated images array
results.updatedImages = [
...results.updatedImages,
...results.uploadResults.successful
];
}
// Reorder images to maintain consistency
results.updatedImages = results.updatedImages.map((img, index) => ({
...img,
order: index
}));
return results;
} catch (error) {
console.error('Error updating product images:', error);
results.errors.push(error.message);
throw error;
}
};
/**
* Generate optimized image transformations for product display
* @param {string} publicId - Cloudinary public ID
* @param {Object} options - Transformation options
* @returns {Object} Object with different image sizes and transformations
*/
export const generateProductImageTransformations = (publicId, options = {}) => {
const {
generateThumbnail = true,
generateMedium = true,
generateLarge = true,
customTransformations = {}
} = options;
const transformations = {
original: generateTransformationUrl(publicId, {
quality: 'auto:good',
fetch_format: 'auto'
})
};
if (generateThumbnail) {
transformations.thumbnail = generateTransformationUrl(publicId, {
width: 300,
height: 300,
crop: 'thumb',
gravity: 'auto',
quality: 'auto:good',
fetch_format: 'auto',
...customTransformations.thumbnail
});
}
if (generateMedium) {
transformations.medium = generateTransformationUrl(publicId, {
width: 600,
height: 600,
crop: 'limit',
quality: 'auto:good',
fetch_format: 'auto',
...customTransformations.medium
});
}
if (generateLarge) {
transformations.large = generateTransformationUrl(publicId, {
width: 1200,
height: 1200,
crop: 'limit',
quality: 'auto:good',
fetch_format: 'auto',
...customTransformations.large
});
}
// Add any custom transformations
Object.keys(customTransformations).forEach(key => {
if (!['thumbnail', 'medium', 'large'].includes(key)) {
transformations[key] = generateTransformationUrl(publicId, customTransformations[key]);
}
});
return transformations;
};
/**
* Clean up orphaned images for a product
* @param {string} productId - Product ID
* @param {Array} validImageIds - Array of valid public IDs that should be kept
* @param {Object} options - Cleanup options
* @returns {Promise<Object>} Cleanup results
*/
export const cleanupOrphanedProductImages = async (productId, validImageIds = [], options = {}) => {
const { folder = 'equipment' } = options;
if (!productId) {
throw new Error('productId is required');
}
try {
// Search for all images in the product folder
const searchExpression = `folder:${folder}/${productId}/*`;
const searchResult = await searchFiles({
expression: searchExpression,
max_results: 100
});
if (!searchResult.resources || searchResult.resources.length === 0) {
return {
found: 0,
orphaned: 0,
deleted: 0,
errors: []
};
}
// Find orphaned images (images that exist in Cloudinary but not in validImageIds)
const orphanedImages = searchResult.resources.filter(resource =>
!validImageIds.includes(resource.public_id)
);
if (orphanedImages.length === 0) {
return {
found: searchResult.resources.length,
orphaned: 0,
deleted: 0,
errors: []
};
}
// Delete orphaned images
const orphanedPublicIds = orphanedImages.map(img => img.public_id);
const deleteResults = await deleteProductImages(orphanedPublicIds);
return {
found: searchResult.resources.length,
orphaned: orphanedImages.length,
deleted: deleteResults.totalDeleted,
failed: deleteResults.totalFailed,
errors: deleteResults.failed.map(f => f.error)
};
} catch (error) {
console.error('Error cleaning up orphaned images:', error);
throw new Error(`Failed to cleanup orphaned images: ${error.message}`);
}
};
/**
* Validate product image files before upload
* @param {Array} files - Array of files to validate
* @param {Object} options - Validation options
* @returns {Object} Validation results
*/
export const validateProductImageFiles = (files, options = {}) => {
const {
maxImages = 3,
maxSize = 5 * 1024 * 1024, // 5MB
allowedTypes = ['image/jpeg', 'image/png', 'image/webp'],
minWidth = 300,
minHeight = 300,
maxWidth = 4000,
maxHeight = 4000
} = options;
const results = {
isValid: true,
errors: [],
warnings: [],
validFiles: [],
invalidFiles: []
};
if (!Array.isArray(files)) {
results.isValid = false;
results.errors.push('Files must be provided as an array');
return results;
}
if (files.length === 0) {
results.isValid = false;
results.errors.push('At least one image file is required');
return results;
}
if (files.length > maxImages) {
results.isValid = false;
results.errors.push(`Maximum ${maxImages} images allowed, got ${files.length}`);
return results;
}
files.forEach((file, index) => {
const fileValidation = validateFile(file, {
maxSize,
allowedTypes
});
const fileResult = {
index,
file,
isValid: fileValidation.isValid,
errors: [...fileValidation.errors],
warnings: []
};
// Additional validations for product images
if (file.width && file.height) {
if (file.width < minWidth || file.height < minHeight) {
fileResult.errors.push(`Image dimensions (${file.width}x${file.height}) are too small. Minimum: ${minWidth}x${minHeight}`);
fileResult.isValid = false;
}
if (file.width > maxWidth || file.height > maxHeight) {
fileResult.warnings.push(`Image dimensions (${file.width}x${file.height}) are very large. Consider resizing for better performance.`);
}
// Check aspect ratio
const aspectRatio = file.width / file.height;
if (aspectRatio < 0.5 || aspectRatio > 2) {
fileResult.warnings.push(`Unusual aspect ratio (${aspectRatio.toFixed(2)}). Square or near-square images work best for product display.`);
}
}
if (fileResult.isValid) {
results.validFiles.push(fileResult);
} else {
results.invalidFiles.push(fileResult);
results.isValid = false;
}
results.errors.push(...fileResult.errors);
results.warnings.push(...fileResult.warnings);
});
return results;
};
export default cloudinary;