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.

573 lines (492 loc) 22.3 kB
import { uploadProductImages, deleteProductImages, updateProductImages, generateProductImageTransformations, cleanupOrphanedProductImages, validateProductImageFiles, searchFiles } from '../utils/cloudinary.js'; /** * ImageManagerService - Centralized service for managing product images * Provides high-level interface for image operations with business logic */ export class ImageManagerService { /** * Handle complete product image update workflow * @param {string} productId - Product ID * @param {Array} currentImages - Current product images * @param {Array} newImages - New image files to upload * @param {Array} imagesToRemove - Public IDs of images to remove * @param {Object} options - Update options * @returns {Promise<Object>} Update results with new image array */ static async handleProductImageUpdate(productId, currentImages = [], newImages = [], imagesToRemove = [], options = {}) { // Ensure arrays are properly initialized currentImages = Array.isArray(currentImages) ? currentImages : []; newImages = Array.isArray(newImages) ? newImages : []; imagesToRemove = Array.isArray(imagesToRemove) ? imagesToRemove : []; const { maxImages = 3, folder = 'equipment', validateBeforeUpload = true, cleanupOrphans = true, generateTransformations = true } = options; try { console.log(`Starting image update for product ${productId}`); // Step 1: Validate new images if any if (newImages.length > 0 && validateBeforeUpload) { console.log(`Validating ${newImages.length} new images...`); const validation = this.validateImageFiles(newImages, { maxImages }); if (!validation.isValid) { throw new Error(`Image validation failed: ${validation.errors.join(', ')}`); } if (validation.warnings.length > 0) { console.warn('Image validation warnings:', validation.warnings); } } // Step 2: Check total image count after update const finalImageCount = (currentImages.length - imagesToRemove.length) + newImages.length; if (finalImageCount > maxImages) { throw new Error(`Total images (${finalImageCount}) would exceed maximum allowed (${maxImages})`); } // Step 3: Perform the update const updateResults = await updateProductImages( currentImages, newImages, productId, imagesToRemove, { maxImages, folder, generateVariants: generateTransformations } ); // Step 4: Generate transformations for new images if needed if (generateTransformations && updateResults.updatedImages.length > 0) { updateResults.updatedImages = updateResults.updatedImages.map(image => { if (!image.transformations && image.publicId) { image.transformations = generateProductImageTransformations(image.publicId); } return image; }); } // Step 5: Cleanup orphaned images if requested if (cleanupOrphans) { console.log('Cleaning up orphaned images...'); const validPublicIds = updateResults.updatedImages.map(img => img.publicId); const cleanupResults = await cleanupOrphanedProductImages(productId, validPublicIds, { folder }); if (cleanupResults.deleted > 0) { console.log(`Cleaned up ${cleanupResults.deleted} orphaned images`); } updateResults.cleanupResults = cleanupResults; } // Step 6: Prepare final response const response = { success: true, productId, images: updateResults.updatedImages, totalImages: updateResults.updatedImages.length, operations: { uploaded: updateResults.uploadResults?.totalUploaded || 0, deleted: updateResults.deleteResults?.totalDeleted || 0, failed: (updateResults.uploadResults?.totalFailed || 0) + (updateResults.deleteResults?.totalFailed || 0) }, errors: updateResults.errors || [], warnings: [] }; // Add warnings for any failures if (response.operations.failed > 0) { response.warnings.push(`${response.operations.failed} image operations failed`); } console.log(`Image update completed for product ${productId}:`, { totalImages: response.totalImages, uploaded: response.operations.uploaded, deleted: response.operations.deleted, failed: response.operations.failed }); return response; } catch (error) { console.error(`Error updating images for product ${productId}:`, error); return { success: false, productId, images: currentImages, // Return original images on error totalImages: currentImages.length, operations: { uploaded: 0, deleted: 0, failed: 0 }, errors: [error.message], warnings: [] }; } } /** * Clean up orphaned images for a product * @param {string} productId - Product ID * @param {Array} validImagePublicIds - Array of valid public IDs to keep * @param {Object} options - Cleanup options * @returns {Promise<Object>} Cleanup results */ static async cleanupOrphanedImages(productId, validImagePublicIds = [], options = {}) { const { folder = 'equipment', dryRun = false } = options; try { console.log(`${dryRun ? 'Simulating' : 'Performing'} orphan cleanup for product ${productId}`); if (dryRun) { // For dry run, just identify orphans without deleting const searchExpression = `folder:${folder}/${productId}/*`; const searchResult = await searchFiles({ expression: searchExpression, max_results: 100 }); const orphanedImages = searchResult.resources?.filter(resource => !validImagePublicIds.includes(resource.public_id) ) || []; return { dryRun: true, found: searchResult.resources?.length || 0, orphaned: orphanedImages.length, deleted: 0, orphanedImages: orphanedImages.map(img => ({ publicId: img.public_id, url: img.secure_url, size: img.bytes, createdAt: img.created_at })) }; } return await cleanupOrphanedProductImages(productId, validImagePublicIds, { folder }); } catch (error) { console.error(`Error cleaning up orphaned images for product ${productId}:`, error); throw new Error(`Failed to cleanup orphaned images: ${error.message}`); } } /** * Validate image files with business logic * @param {Array} files - Files to validate * @param {Object} options - Validation options * @returns {Object} Enhanced validation results */ static validateImageFiles(files, options = {}) { // Ensure files is an array if (!Array.isArray(files)) { return { isValid: false, errors: ['Files parameter must be an array'], warnings: [], businessErrors: ['Invalid files parameter'], businessWarnings: [], recommendations: [], allErrors: ['Files parameter must be an array', 'Invalid files parameter'], allWarnings: [] }; } const { maxImages = 3, maxSize = 5 * 1024 * 1024, // 5MB requiredFormats = ['image/jpeg', 'image/png', 'image/webp'], minDimensions = { width: 300, height: 300 }, maxDimensions = { width: 4000, height: 4000 }, enforceAspectRatio = false, preferredAspectRatio = 1 // 1:1 square } = options; try { const validation = validateProductImageFiles(files, { maxImages, maxSize, allowedTypes: requiredFormats, minWidth: minDimensions.width, minHeight: minDimensions.height, maxWidth: maxDimensions.width, maxHeight: maxDimensions.height }); // Add business-specific validations const businessValidation = { ...validation, businessErrors: [], businessWarnings: [], recommendations: [] }; // Check for duplicate file names const fileNames = files.map(f => f.originalname || f.name).filter(Boolean); const duplicateNames = fileNames.filter((name, index) => fileNames.indexOf(name) !== index); if (duplicateNames.length > 0) { businessValidation.businessWarnings.push(`Duplicate file names detected: ${duplicateNames.join(', ')}`); } // Check total file size const totalSize = files.reduce((sum, file) => sum + (file.size || 0), 0); const totalSizeMB = totalSize / (1024 * 1024); if (totalSizeMB > 15) { // 15MB total limit businessValidation.businessWarnings.push(`Total file size (${totalSizeMB.toFixed(2)}MB) is quite large. Consider optimizing images.`); } // Aspect ratio recommendations if (enforceAspectRatio) { files.forEach((file, index) => { if (file.width && file.height) { const aspectRatio = file.width / file.height; const tolerance = 0.1; if (Math.abs(aspectRatio - preferredAspectRatio) > tolerance) { businessValidation.recommendations.push( `Image ${index + 1}: Consider using ${preferredAspectRatio}:1 aspect ratio for consistency` ); } } }); } // Format recommendations const jpegFiles = files.filter(f => f.mimetype === 'image/jpeg').length; const pngFiles = files.filter(f => f.mimetype === 'image/png').length; if (pngFiles > jpegFiles && files.length > 1) { businessValidation.recommendations.push('Consider using JPEG format for photographs to reduce file size'); } // Combine all errors and warnings businessValidation.allErrors = [ ...businessValidation.errors, ...businessValidation.businessErrors ]; businessValidation.allWarnings = [ ...businessValidation.warnings, ...businessValidation.businessWarnings ]; businessValidation.isValid = businessValidation.allErrors.length === 0; return businessValidation; } catch (error) { console.error('Error validating image files:', error); return { isValid: false, errors: [`Validation error: ${error.message}`], warnings: [], businessErrors: [], businessWarnings: [], recommendations: [], allErrors: [`Validation error: ${error.message}`], allWarnings: [] }; } } /** * Optimize image for upload with compression and resizing * @param {Buffer|File} imageFile - Image file to optimize * @param {Object} options - Optimization options * @returns {Promise<Buffer>} Optimized image buffer */ static async optimizeImageForUpload(imageFile, options = {}) { const { maxWidth = 1200, maxHeight = 1200, quality = 85, format = 'auto', // 'auto', 'jpeg', 'png', 'webp' stripMetadata = true } = options; try { // This is a placeholder for image optimization logic // In a real implementation, you might use libraries like: // - sharp (for Node.js server-side) // - canvas API (for browser client-side) // - ImageMagick bindings console.log(`Optimizing image with options:`, { maxWidth, maxHeight, quality, format, stripMetadata }); // For now, return the original file // TODO: Implement actual image optimization if (Buffer.isBuffer(imageFile)) { return imageFile; } if (imageFile.buffer) { return imageFile.buffer; } // If it's a File object, convert to buffer if (typeof imageFile.arrayBuffer === 'function') { const arrayBuffer = await imageFile.arrayBuffer(); return Buffer.from(arrayBuffer); } throw new Error('Unsupported image file format for optimization'); } catch (error) { console.error('Error optimizing image:', error); throw new Error(`Failed to optimize image: ${error.message}`); } } /** * Generate image metadata for database storage * @param {Object} uploadResult - Cloudinary upload result * @param {Object} options - Metadata options * @returns {Object} Formatted image metadata */ static generateImageMetadata(uploadResult, options = {}) { const { alt = '', caption = '', order = 0, isFeature = false, generateTransformations = true } = options; const metadata = { publicId: uploadResult.public_id, url: uploadResult.secure_url, width: uploadResult.width, height: uploadResult.height, format: uploadResult.format, size: uploadResult.bytes, alt, caption, order, isFeature, uploadedAt: new Date().toISOString(), cloudinaryData: { version: uploadResult.version, signature: uploadResult.signature, resourceType: uploadResult.resource_type, createdAt: uploadResult.created_at, tags: uploadResult.tags || [] } }; // Generate transformations if (generateTransformations) { metadata.transformations = generateProductImageTransformations(uploadResult.public_id); } return metadata; } /** * Batch process multiple image operations * @param {Array} operations - Array of image operations * @returns {Promise<Array>} Results of all operations */ static async batchProcessImages(operations) { if (!Array.isArray(operations) || operations.length === 0) { return []; } const results = []; try { // Process operations in batches to avoid overwhelming Cloudinary const batchSize = 5; const batches = []; for (let i = 0; i < operations.length; i += batchSize) { batches.push(operations.slice(i, i + batchSize)); } for (const batch of batches) { const batchPromises = batch.map(async (operation) => { try { const { type, productId, ...params } = operation; switch (type) { case 'upload': return await this.handleProductImageUpdate( productId, params.currentImages || [], params.newImages || [], [], params.options || {} ); case 'delete': return await this.handleProductImageUpdate( productId, params.currentImages || [], [], params.imagesToDelete || [], params.options || {} ); case 'cleanup': return await this.cleanupOrphanedImages( productId, params.validImageIds || [], params.options || {} ); default: throw new Error(`Unknown operation type: ${type}`); } } catch (error) { return { success: false, operation, error: error.message }; } }); const batchResults = await Promise.allSettled(batchPromises); results.push(...batchResults.map(result => result.status === 'fulfilled' ? result.value : { success: false, error: result.reason.message } )); // Small delay between batches to be respectful to Cloudinary if (batches.indexOf(batch) < batches.length - 1) { await new Promise(resolve => setTimeout(resolve, 100)); } } return results; } catch (error) { console.error('Error in batch processing images:', error); throw new Error(`Batch processing failed: ${error.message}`); } } /** * Get image statistics and analytics * @param {Array} images - Array of image objects * @returns {Object} Image statistics */ static getImageStatistics(images) { if (!Array.isArray(images) || images.length === 0) { return { totalImages: 0, totalSize: 0, averageSize: 0, formats: {}, dimensions: {}, oldestImage: null, newestImage: null }; } const stats = { totalImages: images.length, totalSize: images.reduce((sum, img) => sum + (img.size || img.bytes || 0), 0), formats: {}, dimensions: { minWidth: Infinity, maxWidth: 0, minHeight: Infinity, maxHeight: 0, averageWidth: 0, averageHeight: 0 }, uploadDates: [], transformations: 0 }; let totalWidth = 0; let totalHeight = 0; images.forEach(image => { // Format statistics const format = image.format || 'unknown'; stats.formats[format] = (stats.formats[format] || 0) + 1; // Dimension statistics if (image.width) { stats.dimensions.minWidth = Math.min(stats.dimensions.minWidth, image.width); stats.dimensions.maxWidth = Math.max(stats.dimensions.maxWidth, image.width); totalWidth += image.width; } if (image.height) { stats.dimensions.minHeight = Math.min(stats.dimensions.minHeight, image.height); stats.dimensions.maxHeight = Math.max(stats.dimensions.maxHeight, image.height); totalHeight += image.height; } // Upload dates if (image.uploadedAt) { stats.uploadDates.push(new Date(image.uploadedAt)); } // Transformations if (image.transformations) { stats.transformations++; } }); // Calculate averages stats.averageSize = Math.round(stats.totalSize / images.length); stats.dimensions.averageWidth = Math.round(totalWidth / images.length); stats.dimensions.averageHeight = Math.round(totalHeight / images.length); // Fix infinite values if (stats.dimensions.minWidth === Infinity) stats.dimensions.minWidth = 0; if (stats.dimensions.minHeight === Infinity) stats.dimensions.minHeight = 0; // Date statistics if (stats.uploadDates.length > 0) { stats.uploadDates.sort((a, b) => a - b); stats.oldestImage = stats.uploadDates[0]; stats.newestImage = stats.uploadDates[stats.uploadDates.length - 1]; } return stats; } } export default ImageManagerService;