UNPKG

@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
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;