Add audit log for product import, add tiff image support, add new/preorder filters on product editor, fix sorting in product editor

This commit is contained in:
2026-03-26 10:46:24 -04:00
parent 76a8836769
commit 9643cf191f
13 changed files with 592 additions and 38 deletions
+95 -11
View File
@@ -194,7 +194,7 @@ const processUploadedImage = async (filePath, mimetype) => {
legacyWarnings.push(message);
}
const format = (baseMetadata.format || '').toLowerCase();
let format = (baseMetadata.format || '').toLowerCase();
if (format === 'gif') {
if (metadata.size > MAX_IMAGE_SIZE_BYTES) {
const message = `GIF optimization is limited; resulting size is ${bytesToMegabytes(metadata.size)}MB (target 5MB).`;
@@ -211,6 +211,16 @@ const processUploadedImage = async (filePath, mimetype) => {
return { notices, warnings: legacyWarnings, metadata, finalSize: metadata.size };
}
// TIFF: convert to JPEG (don't store TIFF files)
let convertedFromTiff = false;
if (format === 'tiff') {
convertedFromTiff = true;
format = 'jpeg';
const message = 'Converted from TIFF to JPEG.';
notices.push({ message, level: 'info', code: 'converted_from_tiff', source: 'server' });
legacyWarnings.push(message);
}
const supportsQuality = ['jpeg', 'jpg', 'webp'].includes(format);
let targetQuality = supportsQuality ? 90 : undefined;
let finalQuality = undefined;
@@ -343,8 +353,8 @@ const processUploadedImage = async (filePath, mimetype) => {
});
legacyWarnings.push(message);
}
} else if (shouldConvertToRgb) {
const { data, info } = await encode({ width: currentWidth, height: currentHeight });
} else if (shouldConvertToRgb || convertedFromTiff) {
const { data, info } = await encode({ width: currentWidth, height: currentHeight, quality: targetQuality });
mutated = true;
finalBuffer = data;
finalInfo = info;
@@ -363,6 +373,15 @@ const processUploadedImage = async (filePath, mimetype) => {
metadata.optimizedSize = metadata.size;
}
// Rename TIFF files to .jpg after conversion
let newFilePath = null;
if (convertedFromTiff) {
newFilePath = filePath.replace(/\.tiff?$/i, '.jpg');
if (newFilePath !== filePath) {
await fsp.rename(filePath, newFilePath);
}
}
metadata.convertedToRgb = shouldConvertToRgb && mutated;
metadata.resized = resized;
if (finalQuality) {
@@ -396,6 +415,7 @@ const processUploadedImage = async (filePath, mimetype) => {
warnings: legacyWarnings,
metadata,
finalSize: finalBuffer.length,
newFilePath,
};
};
@@ -419,10 +439,11 @@ const storage = multer.diskStorage({
case 'image/png': fileExt = '.png'; break;
case 'image/gif': fileExt = '.gif'; break;
case 'image/webp': fileExt = '.webp'; break;
case 'image/tiff': fileExt = '.tif'; break;
default: fileExt = '.jpg'; // Default to jpg
}
}
const fileName = `${req.body.upc || 'product'}-${uniqueSuffix}${fileExt}`;
console.log(`Generated filename: ${fileName} with mimetype: ${file.mimetype}`);
cb(null, fileName);
@@ -436,10 +457,10 @@ const upload = multer({
},
fileFilter: function (req, file, cb) {
// Accept only image files
const filetypes = /jpeg|jpg|png|gif|webp/;
const filetypes = /jpeg|jpg|png|gif|webp|tiff?/;
const mimetype = filetypes.test(file.mimetype);
const extname = filetypes.test(path.extname(file.originalname).toLowerCase());
if (mimetype && extname) {
return cb(null, true);
}
@@ -633,13 +654,19 @@ router.post('/upload-image', upload.single('image'), async (req, res) => {
const processingResult = await processUploadedImage(filePath, req.file.mimetype);
req.file.size = processingResult.finalSize;
// If TIFF was converted to JPG, update filename to match the renamed file
const effectivePath = processingResult.newFilePath || filePath;
if (processingResult.newFilePath) {
req.file.filename = path.basename(processingResult.newFilePath);
}
// Create URL for the uploaded file - using an absolute URL with domain
// This will generate a URL like: https://acot.site/uploads/products/filename.jpg
const baseUrl = 'https://tools.acherryontop.com';
const imageUrl = `${baseUrl}/uploads/products/${req.file.filename}`;
// Schedule this image for deletion in 24 hours
scheduleImageDeletion(req.file.filename, filePath);
scheduleImageDeletion(req.file.filename, effectivePath);
// Return success response with image URL
res.status(200).json({
@@ -1308,8 +1335,11 @@ const PRODUCT_SELECT = `
pls.date_sold AS date_last_sold,
IF(p.tax_code IS NULL, '', CAST(p.tax_code AS CHAR)) AS tax_code,
CAST(p.size_cat AS CHAR) AS size_cat,
CAST(p.shipping_restrictions AS CHAR) AS shipping_restrictions
CAST(p.shipping_restrictions AS CHAR) AS shipping_restrictions,
IF(DATEDIFF(NOW(), p.date_ol) <= 45 AND p.notnew = 0 AND (si_feed.all IS NULL OR si_feed.all != 2), 1, 0) AS is_new,
IF(si_feed.all = 2, 1, 0) AS is_preorder
FROM products p
LEFT JOIN shop_inventory si_feed ON p.pid = si_feed.pid AND si_feed.store = 0
LEFT JOIN product_current_prices pcp ON p.pid = pcp.pid AND pcp.active = 1
LEFT JOIN supplier_item_data sid ON p.pid = sid.pid
LEFT JOIN suppliers s ON sid.supplier_id = s.supplierid
@@ -1334,7 +1364,7 @@ router.get('/line-products', async (req, res) => {
where += ' AND p.subline = ?';
params.push(Number(subline));
}
const query = `${PRODUCT_SELECT} ${where} GROUP BY p.pid ORDER BY p.description`;
const query = `${PRODUCT_SELECT} ${where} GROUP BY p.pid ORDER BY IF(p.date_ol != '0000-00-00 00:00:00', p.date_ol, p.date_created) DESC, p.description`;
const [results] = await connection.query(query, params);
res.json(results);
} catch (error) {
@@ -1501,7 +1531,7 @@ router.get('/path-products', async (req, res) => {
return res.status(400).json({ error: 'No valid filters found in path' });
}
const query = `${PRODUCT_SELECT} WHERE ${whereParts.join(' AND ')} GROUP BY p.pid ORDER BY p.description`;
const query = `${PRODUCT_SELECT} WHERE ${whereParts.join(' AND ')} GROUP BY p.pid ORDER BY IF(p.date_ol != '0000-00-00 00:00:00', p.date_ol, p.date_created) DESC, p.description`;
const [results] = await connection.query(query, params);
res.json(results);
} catch (error) {
@@ -1552,6 +1582,60 @@ router.get('/product-images/:pid', async (req, res) => {
}
});
// Batch fetch product images for multiple PIDs
router.get('/product-images-batch', async (req, res) => {
const { pids } = req.query;
if (!pids) {
return res.status(400).json({ error: 'pids query parameter is required' });
}
const pidList = String(pids).split(',').map(Number).filter(n => n > 0);
if (pidList.length === 0) {
return res.json({});
}
try {
const { connection } = await getDbConnection();
const placeholders = pidList.map(() => '?').join(',');
const [rows] = await connection.query(
`SELECT pid, iid, type, width, height, \`order\`, hidden FROM product_images WHERE pid IN (${placeholders}) ORDER BY \`order\` DESC, type`,
pidList
);
const typeMap = { 1: 'o', 2: 'l', 3: 't', 4: '100x100', 5: '175x175', 6: '300x300', 7: '600x600', 8: '500x500', 9: '150x150' };
const result = {};
for (const pid of pidList) {
result[pid] = {};
}
for (const row of rows) {
const typeName = typeMap[row.type];
if (!typeName) continue;
const pid = row.pid;
if (!result[pid]) result[pid] = {};
if (!result[pid][row.iid]) {
result[pid][row.iid] = { iid: row.iid, order: row.order, hidden: !!row.hidden, sizes: {} };
}
const padded = String(pid).padStart(10, '0');
const pathPrefix = `${padded.substring(0, 4)}/${padded.substring(4, 7)}/`;
result[pid][row.iid].sizes[typeName] = {
width: row.width,
height: row.height,
url: `https://sbing.com/i/products/${pathPrefix}${pid}-${typeName}-${row.iid}.jpg`,
};
}
// Convert each pid's iid map to sorted array
const output = {};
for (const pid of pidList) {
output[pid] = Object.values(result[pid] || {}).sort((a, b) => b.order - a.order);
}
res.json(output);
} catch (error) {
console.error('Error fetching batch product images:', error);
res.status(500).json({ error: 'Failed to fetch product images' });
}
});
const UPC_SUPPLIER_PREFIX_LEADING_DIGIT = '4';
const UPC_MAX_SEQUENCE = 99999;
const UPC_RESERVATION_TTL = 5 * 60 * 1000; // 5 minutes