monacousa-portal/server/utils/profile-images.ts

297 lines
8.5 KiB
TypeScript

import sharp from 'sharp';
import { uploadFile, deleteFile, getPreviewUrl } from './minio';
import { getNocoDbConfiguration, Table, createTableUrl } from './nocodb';
import type { Member } from '~/utils/types';
import type { EntityResponse } from './nocodb';
interface ThumbnailSizes {
small: { width: 64, height: 64 };
medium: { width: 200, height: 200 };
}
const THUMBNAIL_SIZES: ThumbnailSizes = {
small: { width: 64, height: 64 }, // For header avatar
medium: { width: 200, height: 200 } // For profile cards and profile page
};
/**
* Upload profile image with automatic thumbnail generation
*/
export const uploadProfileImage = async (
memberId: string,
buffer: Buffer,
originalName: string
): Promise<string> => {
if (!memberId) {
throw new Error('Member ID is required for profile image upload');
}
try {
// Validate image format
const metadata = await sharp(buffer).metadata();
if (!metadata.format || !['jpeg', 'jpg', 'png', 'webp'].includes(metadata.format)) {
throw new Error('Invalid image format. Only JPEG, PNG, and WebP are supported.');
}
// Ensure minimum dimensions
if (!metadata.width || !metadata.height || metadata.width < 100 || metadata.height < 100) {
throw new Error('Image must be at least 100x100 pixels.');
}
const basePath = `profile-images/${memberId}`;
const timestamp = Date.now();
// Process original image (convert to JPEG for consistency)
const processedBuffer = await sharp(buffer)
.jpeg({ quality: 90, mozjpeg: true })
.toBuffer();
const originalPath = `${basePath}/profile.jpg`;
// Generate thumbnails
const thumbnails: Record<string, Buffer> = {};
for (const [sizeName, dimensions] of Object.entries(THUMBNAIL_SIZES)) {
thumbnails[sizeName] = await sharp(buffer)
.resize(dimensions.width, dimensions.height, {
fit: 'cover',
position: 'center'
})
.jpeg({ quality: 85 })
.toBuffer();
}
// Upload original image
await uploadFile(originalPath, processedBuffer, 'image/jpeg');
console.log(`✅ Uploaded profile image: ${originalPath}`);
// Upload thumbnails
for (const [sizeName, thumbnailBuffer] of Object.entries(thumbnails)) {
const thumbnailPath = `${basePath}/profile_${sizeName}.jpg`;
await uploadFile(thumbnailPath, thumbnailBuffer, 'image/jpeg');
console.log(`✅ Generated ${sizeName} thumbnail: ${thumbnailPath}`);
}
return originalPath;
} catch (error) {
console.error('Failed to upload profile image:', error);
throw error;
}
};
/**
* Delete profile image and all associated thumbnails
*/
export const deleteProfileImage = async (memberId: string): Promise<void> => {
if (!memberId) {
throw new Error('Member ID is required for profile image deletion');
}
try {
const basePath = `profile-images/${memberId}`;
// Delete original image
try {
await deleteFile(`${basePath}/profile.jpg`);
console.log(`🗑️ Deleted profile image: ${basePath}/profile.jpg`);
} catch (error) {
console.warn('Original profile image not found or already deleted');
}
// Delete thumbnails
for (const sizeName of Object.keys(THUMBNAIL_SIZES)) {
try {
await deleteFile(`${basePath}/profile_${sizeName}.jpg`);
console.log(`🗑️ Deleted ${sizeName} thumbnail: ${basePath}/profile_${sizeName}.jpg`);
} catch (error) {
console.warn(`${sizeName} thumbnail not found or already deleted`);
}
}
} catch (error) {
console.error('Failed to delete profile image:', error);
throw error;
}
};
/**
* Get profile image URL for display
*/
export const getProfileImageUrl = async (
memberId: string,
size: 'original' | 'small' | 'medium' = 'original'
): Promise<string | null> => {
if (!memberId) {
return null;
}
try {
const basePath = `profile-images/${memberId}`;
let imagePath: string;
if (size === 'original') {
imagePath = `${basePath}/profile.jpg`;
} else {
imagePath = `${basePath}/profile_${size}.jpg`;
}
// Generate presigned URL
const url = await getPreviewUrl(imagePath, 'image/jpeg');
return url;
} catch (error) {
console.warn(`Profile image not found for member ${memberId}, size ${size}`);
return null;
}
};
/**
* Update member profile image URL in NocoDB
*/
export const updateMemberProfileImageUrl = async (
memberId: string,
imagePath: string,
originalName: string
): Promise<void> => {
try {
// Find member by member_id using existing pattern
const response = await $fetch<EntityResponse<Member>>(createTableUrl(Table.Members), {
headers: {
"xc-token": getNocoDbConfiguration().token,
},
params: {
where: `(member_id,eq,${memberId})`,
limit: 1,
},
});
if (!response.list || response.list.length === 0) {
throw new Error(`Member not found with ID: ${memberId}`);
}
const member = response.list[0];
// Update profile image fields using PATCH method
const updateData = {
Id: parseInt(member.Id),
profile_image_url: imagePath,
profile_image_updated_at: new Date().toISOString(),
profile_image_original_name: originalName
};
await $fetch(createTableUrl(Table.Members), {
method: "PATCH",
headers: {
"xc-token": getNocoDbConfiguration().token,
"Content-Type": "application/json"
},
body: updateData
});
console.log(`✅ Updated profile image URL for member ${memberId}`);
} catch (error) {
console.error('Failed to update member profile image URL:', error);
throw error;
}
};
/**
* Remove member profile image URL from NocoDB
*/
export const removeMemberProfileImageUrl = async (memberId: string): Promise<void> => {
try {
// Find member by member_id using existing pattern
const response = await $fetch<EntityResponse<Member>>(createTableUrl(Table.Members), {
headers: {
"xc-token": getNocoDbConfiguration().token,
},
params: {
where: `(member_id,eq,${memberId})`,
limit: 1,
},
});
if (!response.list || response.list.length === 0) {
throw new Error(`Member not found with ID: ${memberId}`);
}
const member = response.list[0];
// Clear profile image fields using PATCH method
const updateData = {
Id: parseInt(member.Id),
profile_image_url: null,
profile_image_updated_at: new Date().toISOString(),
profile_image_original_name: null
};
await $fetch(createTableUrl(Table.Members), {
method: "PATCH",
headers: {
"xc-token": getNocoDbConfiguration().token,
"Content-Type": "application/json"
},
body: updateData
});
console.log(`✅ Removed profile image URL for member ${memberId}`);
} catch (error) {
console.error('Failed to remove member profile image URL:', error);
throw error;
}
};
/**
* Validate image file before processing
*/
export const validateImageFile = (buffer: Buffer, filename: string): void => {
// Check file size (5MB max)
const maxSize = 5 * 1024 * 1024;
if (buffer.length > maxSize) {
throw new Error('Image file size must be less than 5MB');
}
// Check file extension
const allowedExtensions = ['.jpg', '.jpeg', '.png', '.webp'];
const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
if (!allowedExtensions.includes(extension)) {
throw new Error('Only JPEG, PNG, and WebP image files are allowed');
}
};
/**
* Generate user initials from name (fallback for missing profile images)
*/
export const generateInitials = (firstName?: string, lastName?: string, fullName?: string): string => {
if (firstName && lastName) {
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
}
if (fullName) {
const names = fullName.trim().split(' ');
if (names.length >= 2) {
return `${names[0].charAt(0)}${names[names.length - 1].charAt(0)}`.toUpperCase();
}
return names[0].charAt(0).toUpperCase();
}
return '?';
};
/**
* Generate background color for initials based on name
*/
export const generateAvatarColor = (name: string): string => {
const colors = [
'#f44336', '#e91e63', '#9c27b0', '#673ab7',
'#3f51b5', '#2196f3', '#03a9f4', '#00bcd4',
'#009688', '#4caf50', '#8bc34a', '#cddc39',
'#ff9800', '#ff5722', '#795548', '#607d8b'
];
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const index = Math.abs(hash) % colors.length;
return colors[index];
};