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