import express from 'express'; import { Client } from 'ssh2'; import mysql from 'mysql2/promise'; import multer from 'multer'; import path from 'node:path'; import fs from 'node:fs'; import sharp from 'sharp'; import axios from 'axios'; import net from 'node:net'; import { requirePermission } from '../../shared/auth/middleware.js'; const router = express.Router(); const fsp = fs.promises; // Phase 6.2: imports, uploads, generate-upc and deletions all require product_import. // Reads (list-uploads, status checks) remain authenticated-only. router.use((req, res, next) => { if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next(); return requirePermission('product_import')(req, res, next); }); // Create uploads directory if it doesn't exist const uploadsDir = path.join('/var/www/inventory/uploads/products'); const reusableUploadsDir = path.join('/var/www/inventory/uploads/reusable'); fs.mkdirSync(uploadsDir, { recursive: true }); fs.mkdirSync(reusableUploadsDir, { recursive: true }); // Create a Map to track image upload times and their scheduled deletion const imageUploadMap = new Map(); // Connection pooling and cache configuration const connectionCache = { ssh: null, dbConnection: null, lastUsed: 0, isConnecting: false, connectionPromise: null, // Cache expiration time in milliseconds (5 minutes) expirationTime: 5 * 60 * 1000, // Cache for query results (key: query string, value: {data, timestamp}) queryCache: new Map(), // Cache duration for different query types in milliseconds cacheDuration: { 'field-options': 30 * 60 * 1000, // 30 minutes for field options 'product-lines': 10 * 60 * 1000, // 10 minutes for product lines 'sublines': 10 * 60 * 1000, // 10 minutes for sublines 'default': 60 * 1000 // 1 minute default } }; const MIN_IMAGE_DIMENSION = 1000; const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; // Function to schedule image deletion after 24 hours const scheduleImageDeletion = (filename, filePath) => { // Only schedule deletion for images in the products folder if (!filePath.includes('/uploads/products/')) { console.log(`Skipping deletion for non-product image: ${filename}`); return; } // Delete any existing timeout for this file if (imageUploadMap.has(filename)) { clearTimeout(imageUploadMap.get(filename).timeoutId); } // Schedule deletion after 24 hours (24 * 60 * 60 * 1000 ms) const timeoutId = setTimeout(() => { console.log(`Auto-deleting image after 24 hours: ${filename}`); // Check if file exists before trying to delete if (fs.existsSync(filePath)) { try { fs.unlinkSync(filePath); console.log(`Successfully auto-deleted image: ${filename}`); } catch (error) { console.error(`Error auto-deleting image ${filename}:`, error); } } else { console.log(`File already deleted: ${filename}`); } // Remove from tracking map imageUploadMap.delete(filename); }, 24 * 60 * 60 * 1000); // 24 hours // Store upload time and timeout ID imageUploadMap.set(filename, { uploadTime: new Date(), timeoutId: timeoutId, filePath: filePath }); }; // Function to clean up scheduled deletions on server restart const cleanupImagesOnStartup = () => { console.log('Checking for images to clean up...'); // Check if uploads directory exists if (!fs.existsSync(uploadsDir)) { console.log('Uploads directory does not exist'); return; } // Read all files in the directory fs.readdir(uploadsDir, (err, files) => { if (err) { console.error('Error reading uploads directory:', err); return; } const now = new Date(); let countDeleted = 0; files.forEach(filename => { const filePath = path.join(uploadsDir, filename); // Get file stats try { const stats = fs.statSync(filePath); const fileCreationTime = stats.birthtime || stats.ctime; // birthtime might not be available on all systems const ageMs = now.getTime() - fileCreationTime.getTime(); // If file is older than 24 hours, delete it if (ageMs > 24 * 60 * 60 * 1000) { fs.unlinkSync(filePath); countDeleted++; console.log(`Deleted old image on startup: ${filename} (age: ${Math.round(ageMs / (60 * 60 * 1000))} hours)`); } else { // Schedule deletion for remaining time const remainingMs = (24 * 60 * 60 * 1000) - ageMs; console.log(`Scheduling deletion for ${filename} in ${Math.round(remainingMs / (60 * 60 * 1000))} hours`); const timeoutId = setTimeout(() => { if (fs.existsSync(filePath)) { try { fs.unlinkSync(filePath); console.log(`Successfully auto-deleted scheduled image: ${filename}`); } catch (error) { console.error(`Error auto-deleting scheduled image ${filename}:`, error); } } imageUploadMap.delete(filename); }, remainingMs); imageUploadMap.set(filename, { uploadTime: fileCreationTime, timeoutId: timeoutId, filePath: filePath }); } } catch (error) { console.error(`Error processing file ${filename}:`, error); } }); console.log(`Cleanup completed: ${countDeleted} old images deleted, ${imageUploadMap.size} images scheduled for deletion`); }); }; // Run cleanup on server start cleanupImagesOnStartup(); const bytesToMegabytes = (bytes) => Number((bytes / (1024 * 1024)).toFixed(2)); const getExtensionForImage = (mimetype, sourceName = '') => { const sourceExt = path.extname(sourceName).toLowerCase(); if (sourceExt) return sourceExt; switch (mimetype) { case 'image/jpeg': return '.jpg'; case 'image/png': return '.png'; case 'image/gif': return '.gif'; case 'image/webp': return '.webp'; case 'image/tiff': return '.tif'; default: return '.jpg'; } }; const createUploadFilename = (prefix, extension) => { const safePrefix = String(prefix || 'product').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 80) || 'product'; const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); return `${safePrefix}-${uniqueSuffix}${extension}`; }; const buildUploadedImageResponse = (reqFile, processingResult, filePath) => { reqFile.size = processingResult.finalSize; const effectivePath = processingResult.newFilePath || filePath; if (processingResult.newFilePath) { reqFile.filename = path.basename(processingResult.newFilePath); reqFile.mimetype = 'image/jpeg'; } const baseUrl = 'https://tools.acherryontop.com'; const imageUrl = `${baseUrl}/uploads/products/${reqFile.filename}`; scheduleImageDeletion(reqFile.filename, effectivePath); return { success: true, imageUrl, fileName: reqFile.filename, mimetype: reqFile.mimetype, fullPath: effectivePath, notices: processingResult.notices, warnings: processingResult.warnings, metadata: processingResult.metadata, message: 'Image uploaded successfully (will auto-delete after 24 hours)' }; }; const isBlockedImageSourceHost = (hostname) => { const host = String(hostname || '').toLowerCase(); if (!host || host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) { return true; } const ipVersion = net.isIP(host); if (ipVersion === 4) { const [first, second] = host.split('.').map(Number); return ( first === 10 || first === 127 || (first === 169 && second === 254) || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168) ); } if (ipVersion === 6) { return host === '::1' || host.startsWith('fc') || host.startsWith('fd') || host.startsWith('fe80'); } return false; }; const processUploadedImage = async (filePath, mimetype) => { const notices = []; const legacyWarnings = []; const metadata = {}; const originalBuffer = await fsp.readFile(filePath); let baseMetadata = await sharp(originalBuffer, { failOn: 'none' }).metadata(); metadata.width = baseMetadata.width || 0; metadata.height = baseMetadata.height || 0; metadata.size = originalBuffer.length; metadata.colorSpace = baseMetadata.space || baseMetadata.colourspace || null; if ( baseMetadata.width && baseMetadata.height && (baseMetadata.width < MIN_IMAGE_DIMENSION || baseMetadata.height < MIN_IMAGE_DIMENSION) ) { const message = `Image is ${baseMetadata.width}x${baseMetadata.height}. Recommended minimum is ${MIN_IMAGE_DIMENSION}x${MIN_IMAGE_DIMENSION}.`; notices.push({ message, level: 'warning', code: 'dimensions_too_small', source: 'server' }); legacyWarnings.push(message); } const colorSpace = (baseMetadata.space || baseMetadata.colourspace || '').toLowerCase(); let shouldConvertToRgb = colorSpace === 'cmyk'; if (shouldConvertToRgb) { const message = 'Converted image from CMYK to RGB.'; notices.push({ message, level: 'info', code: 'converted_to_rgb', source: 'server' }); legacyWarnings.push(message); } 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).`; notices.push({ message, level: 'warning', code: 'gif_size_limit', source: 'server' }); legacyWarnings.push(message); } metadata.convertedToRgb = false; metadata.resized = false; return { notices, warnings: legacyWarnings, metadata, finalSize: metadata.size }; } // Convert unsupported product image storage formats to JPEG. let convertedToJpegFrom = null; if (format === 'tiff' || format === 'webp') { convertedToJpegFrom = format; format = 'jpeg'; const sourceLabel = convertedToJpegFrom === 'tiff' ? 'TIFF' : 'WebP'; const message = `Converted from ${sourceLabel} to JPEG.`; notices.push({ message, level: 'info', code: `converted_from_${convertedToJpegFrom}`, source: 'server' }); legacyWarnings.push(message); } const supportsQuality = ['jpeg', 'jpg', 'webp'].includes(format); let targetQuality = supportsQuality ? 90 : undefined; let finalQuality = undefined; let currentWidth = baseMetadata.width || null; let currentHeight = baseMetadata.height || null; let resized = false; let mutated = false; let finalBuffer = originalBuffer; let finalInfo = baseMetadata; const encode = async ({ width, height, quality }) => { let pipeline = sharp(originalBuffer, { failOn: 'none' }); if (shouldConvertToRgb) { pipeline = pipeline.toColorspace('srgb'); } if (width || height) { pipeline = pipeline.resize({ width: width ?? undefined, height: height ?? undefined, fit: 'inside', withoutEnlargement: true, }); } switch (format) { case 'png': pipeline = pipeline.png({ compressionLevel: 9, adaptiveFiltering: true, palette: true, }); break; case 'webp': pipeline = pipeline.webp({ quality: quality ?? 90 }); break; case 'jpeg': case 'jpg': default: pipeline = pipeline.jpeg({ quality: quality ?? 90, mozjpeg: true }); break; } return pipeline.toBuffer({ resolveWithObject: true }); }; const canResize = (currentWidth && currentWidth > MIN_IMAGE_DIMENSION) || (currentHeight && currentHeight > MIN_IMAGE_DIMENSION); if (metadata.size > MAX_IMAGE_SIZE_BYTES && (supportsQuality || canResize)) { const maxAttempts = 8; for (let attempt = 0; attempt < maxAttempts; attempt++) { let targetWidth = currentWidth; let targetHeight = currentHeight; let resizedThisAttempt = false; if (currentWidth && currentWidth > MIN_IMAGE_DIMENSION) { targetWidth = Math.max(MIN_IMAGE_DIMENSION, Math.round(currentWidth * 0.85)); } if (currentHeight && currentHeight > MIN_IMAGE_DIMENSION) { targetHeight = Math.max(MIN_IMAGE_DIMENSION, Math.round(currentHeight * 0.85)); } if ( (targetWidth && currentWidth && targetWidth < currentWidth) || (targetHeight && currentHeight && targetHeight < currentHeight) ) { resized = true; resizedThisAttempt = true; currentWidth = targetWidth; currentHeight = targetHeight; } else if (!supportsQuality || (targetQuality && targetQuality <= 70)) { // Cannot resize further and quality cannot be adjusted break; } const qualityForAttempt = supportsQuality ? targetQuality : undefined; const { data, info } = await encode({ width: currentWidth, height: currentHeight, quality: qualityForAttempt, }); mutated = true; finalBuffer = data; finalInfo = info; metadata.optimizedSize = data.length; if (info.width) metadata.width = info.width; if (info.height) metadata.height = info.height; if (info.width) currentWidth = info.width; if (info.height) currentHeight = info.height; if (supportsQuality && qualityForAttempt) { finalQuality = qualityForAttempt; } if (data.length <= MAX_IMAGE_SIZE_BYTES) { break; } if (resizedThisAttempt) { continue; } if (supportsQuality && targetQuality && targetQuality > 70) { const nextQuality = Math.max(70, targetQuality - 10); if (nextQuality === targetQuality) { break; } targetQuality = nextQuality; continue; } break; } if (finalBuffer.length > MAX_IMAGE_SIZE_BYTES) { const message = `Optimized image remains ${bytesToMegabytes(finalBuffer.length)}MB (target 5MB).`; notices.push({ message, level: 'warning', code: 'size_over_limit', source: 'server' }); legacyWarnings.push(message); } } else if (shouldConvertToRgb || convertedToJpegFrom) { const { data, info } = await encode({ width: currentWidth, height: currentHeight, quality: targetQuality }); mutated = true; finalBuffer = data; finalInfo = info; metadata.optimizedSize = data.length; if (info.width) metadata.width = info.width; if (info.height) metadata.height = info.height; if (info.width) currentWidth = info.width; if (info.height) currentHeight = info.height; } if (mutated) { await fsp.writeFile(filePath, finalBuffer); metadata.optimizedSize = finalBuffer.length; } else { // No transformation occurred; still need to ensure we report original stats metadata.optimizedSize = metadata.size; } // Rename converted source files to .jpg after conversion let newFilePath = null; if (convertedToJpegFrom) { newFilePath = filePath.replace(/\.(tiff?|webp)$/i, '.jpg'); if (newFilePath !== filePath) { await fsp.rename(filePath, newFilePath); } } metadata.convertedToRgb = shouldConvertToRgb && mutated; metadata.resized = resized; if (finalQuality) { metadata.quality = finalQuality; } if (resized && metadata.width && metadata.height) { const message = `Image resized to ${metadata.width}x${metadata.height} during optimization.`; notices.push({ message, level: 'info', code: 'resized', source: 'server' }); legacyWarnings.push(message); } if (finalQuality && finalQuality < 90) { const message = `Image quality adjusted to ${finalQuality} to reduce file size.`; notices.push({ message, level: 'info', code: 'quality_adjusted', source: 'server' }); legacyWarnings.push(message); } return { notices, warnings: legacyWarnings, metadata, finalSize: finalBuffer.length, newFilePath, }; }; // Configure multer for file uploads const storage = multer.diskStorage({ destination: function (req, file, cb) { console.log(`Saving to: ${uploadsDir}`); cb(null, uploadsDir); }, filename: function (req, file, cb) { const fileExt = getExtensionForImage(file.mimetype, file.originalname); const fileName = createUploadFilename(req.body.upc || 'product', fileExt); console.log(`Generated filename: ${fileName} with mimetype: ${file.mimetype}`); cb(null, fileName); } }); const MAX_UPLOAD_BYTES = 25 * 1024 * 1024; // Phase 6.7: exact-match MIME + extension allowlist. Substring-based regex // matchers (the previous /jpeg|png|.../ approach) accepted MIMEs like // `application/jpeg-payload` because of partial matches; this rejects them. const ALLOWED_MIME_TYPES = new Set([ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/tiff', ]); const ALLOWED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.tif', '.tiff']); const upload = multer({ storage: storage, limits: { fileSize: MAX_UPLOAD_BYTES, files: 1, }, fileFilter: function (req, file, cb) { const ext = path.extname(file.originalname).toLowerCase(); if (!ALLOWED_MIME_TYPES.has(file.mimetype) || !ALLOWED_EXTENSIONS.has(ext)) { return cb(new Error('Only image files are allowed (jpg, png, gif, webp, tiff)')); } cb(null, true); } }); // Modified function to get a database connection with connection pooling async function getDbConnection() { const now = Date.now(); // Check if we need to refresh the connection due to inactivity const needsRefresh = !connectionCache.ssh || !connectionCache.dbConnection || (now - connectionCache.lastUsed > connectionCache.expirationTime); // If connection is still valid, update last used time and return existing connection if (!needsRefresh) { connectionCache.lastUsed = now; return { ssh: connectionCache.ssh, connection: connectionCache.dbConnection }; } // If another request is already establishing a connection, wait for that promise if (connectionCache.isConnecting && connectionCache.connectionPromise) { try { await connectionCache.connectionPromise; return { ssh: connectionCache.ssh, connection: connectionCache.dbConnection }; } catch (error) { // If that connection attempt failed, we'll try again below console.error('Error waiting for existing connection:', error); } } // Close existing connections if they exist if (connectionCache.dbConnection) { try { await connectionCache.dbConnection.end(); } catch (error) { console.error('Error closing existing database connection:', error); } } if (connectionCache.ssh) { try { connectionCache.ssh.end(); } catch (error) { console.error('Error closing existing SSH connection:', error); } } // Mark that we're establishing a new connection connectionCache.isConnecting = true; // Create a new promise for this connection attempt connectionCache.connectionPromise = setupSshTunnel().then(tunnel => { const { ssh, stream, dbConfig } = tunnel; return mysql.createConnection({ ...dbConfig, stream }).then(connection => { // Store the new connections connectionCache.ssh = ssh; connectionCache.dbConnection = connection; connectionCache.lastUsed = Date.now(); connectionCache.isConnecting = false; return { ssh, connection }; }); }).catch(error => { connectionCache.isConnecting = false; throw error; }); // Wait for the connection to be established return connectionCache.connectionPromise; } // Helper function to get cached query results or execute query if not cached async function getCachedQuery(cacheKey, queryType, queryFn) { // Get cache duration based on query type const cacheDuration = connectionCache.cacheDuration[queryType] || connectionCache.cacheDuration.default; // Check if we have a valid cached result const cachedResult = connectionCache.queryCache.get(cacheKey); const now = Date.now(); if (cachedResult && (now - cachedResult.timestamp < cacheDuration)) { console.log(`Cache hit for ${queryType} query: ${cacheKey}`); return cachedResult.data; } // No valid cache found, execute the query console.log(`Cache miss for ${queryType} query: ${cacheKey}`); const result = await queryFn(); // Cache the result connectionCache.queryCache.set(cacheKey, { data: result, timestamp: now }); return result; } // Helper function to setup SSH tunnel - ONLY USED BY getDbConnection NOW async function setupSshTunnel() { const sshConfig = { host: process.env.PROD_SSH_HOST, port: process.env.PROD_SSH_PORT || 22, username: process.env.PROD_SSH_USER, privateKey: process.env.PROD_SSH_KEY_PATH ? fs.readFileSync(process.env.PROD_SSH_KEY_PATH) : undefined, compress: true }; const dbConfig = { host: process.env.PROD_DB_HOST || 'localhost', user: process.env.PROD_DB_USER, password: process.env.PROD_DB_PASSWORD, database: process.env.PROD_DB_NAME, port: process.env.PROD_DB_PORT || 3306, // DATETIME columns store Central wall-clock literals — return them as // strings; parse with shared/business-time parseMySql() if instants are // ever needed (see docs/TIME.md). dateStrings: true }; return new Promise((resolve, reject) => { const ssh = new Client(); ssh.on('error', (err) => { console.error('SSH connection error:', err); reject(err); }); ssh.on('ready', () => { ssh.forwardOut( '127.0.0.1', 0, dbConfig.host, dbConfig.port, (err, stream) => { if (err) reject(err); resolve({ ssh, stream, dbConfig }); } ); }).connect(sshConfig); }); } // Image upload endpoint router.post('/upload-image', upload.single('image'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: 'No image file provided' }); } // Log file information console.log('File uploaded:', { filename: req.file.filename, originalname: req.file.originalname, mimetype: req.file.mimetype, size: req.file.size, path: req.file.path }); // Ensure the file exists const filePath = path.join(uploadsDir, req.file.filename); if (!fs.existsSync(filePath)) { return res.status(500).json({ error: 'File was not saved correctly' }); } // Log file access permissions fs.access(filePath, fs.constants.R_OK, (err) => { if (err) { console.error('File permission issue:', err); } else { console.log('File is readable'); } }); // Process the image (resize/compress/color-space) before responding const processingResult = await processUploadedImage(filePath, req.file.mimetype); const responsePayload = buildUploadedImageResponse(req.file, processingResult, filePath); // Return success response with image URL res.status(200).json(responsePayload); } catch (error) { console.error('Error uploading image:', error); if (req?.file?.filename) { const cleanupPath = path.join(uploadsDir, req.file.filename); if (fs.existsSync(cleanupPath)) { try { fs.unlinkSync(cleanupPath); } catch (cleanupError) { console.error('Failed to remove file after processing error:', cleanupError); } } } res.status(500).json({ error: error.message || 'Failed to upload image' }); } }); router.post('/upload-image-url', async (req, res) => { let filePath = null; try { const { imageUrl, upc, supplier_no } = req.body || {}; if (!imageUrl || typeof imageUrl !== 'string') { return res.status(400).json({ error: 'imageUrl is required' }); } let parsedUrl; try { parsedUrl = new URL(imageUrl.trim()); } catch { return res.status(400).json({ error: 'Invalid image URL' }); } if (!['http:', 'https:'].includes(parsedUrl.protocol)) { return res.status(400).json({ error: 'Image URL must use http or https' }); } if (isBlockedImageSourceHost(parsedUrl.hostname)) { return res.status(400).json({ error: 'Image URL host is not allowed' }); } const response = await axios.get(parsedUrl.toString(), { responseType: 'arraybuffer', timeout: 15000, maxContentLength: 15 * 1024 * 1024, maxBodyLength: 15 * 1024 * 1024, headers: { Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', 'User-Agent': 'inventory-image-import/1.0' } }); const contentType = String(response.headers['content-type'] || '').split(';')[0].toLowerCase(); if (!contentType.startsWith('image/')) { return res.status(400).json({ error: 'URL did not return an image' }); } const imageBuffer = Buffer.from(response.data); if (!imageBuffer.length) { return res.status(400).json({ error: 'Downloaded image was empty' }); } const fileExt = getExtensionForImage(contentType, parsedUrl.pathname); const filename = createUploadFilename(upc || supplier_no || 'product-url', fileExt); filePath = path.join(uploadsDir, filename); await fsp.writeFile(filePath, imageBuffer); const reqFile = { filename, originalname: path.basename(parsedUrl.pathname) || filename, mimetype: contentType, size: imageBuffer.length, path: filePath }; console.log('Image URL downloaded:', { filename: reqFile.filename, originalUrl: parsedUrl.toString(), mimetype: reqFile.mimetype, size: reqFile.size, path: reqFile.path }); const processingResult = await processUploadedImage(filePath, reqFile.mimetype); const responsePayload = buildUploadedImageResponse(reqFile, processingResult, filePath); res.status(200).json(responsePayload); } catch (error) { console.error('Error uploading image from URL:', error); if (filePath && fs.existsSync(filePath)) { try { fs.unlinkSync(filePath); } catch (cleanupError) { console.error('Failed to remove URL-downloaded file after processing error:', cleanupError); } } res.status(500).json({ error: error.message || 'Failed to upload image from URL' }); } }); // Image deletion endpoint router.delete('/delete-image', (req, res) => { try { const { filename } = req.body; if (!filename) { return res.status(400).json({ error: 'Filename is required' }); } const filePath = path.join(uploadsDir, filename); // Check if file exists if (!fs.existsSync(filePath)) { return res.status(404).json({ error: 'File not found' }); } // Only allow deletion of images in the products folder if (!filePath.includes('/uploads/products/')) { return res.status(403).json({ error: 'Cannot delete images outside the products folder', message: 'This image is in a protected folder and cannot be deleted through this endpoint' }); } // Delete the file fs.unlinkSync(filePath); // Clear any scheduled deletion for this file if (imageUploadMap.has(filename)) { clearTimeout(imageUploadMap.get(filename).timeoutId); imageUploadMap.delete(filename); } // Return success response res.status(200).json({ success: true, message: 'Image deleted successfully' }); } catch (error) { console.error('Error deleting image:', error); res.status(500).json({ error: error.message || 'Failed to delete image' }); } }); // Clear all taxonomy caches router.post('/clear-taxonomy-cache', (req, res) => { try { // Clear all entries from the query cache const cacheSize = connectionCache.queryCache.size; connectionCache.queryCache.clear(); console.log(`Cleared ${cacheSize} entries from taxonomy cache`); res.json({ success: true, message: `Cache cleared (${cacheSize} entries removed)`, clearedEntries: cacheSize }); } catch (error) { console.error('Error clearing taxonomy cache:', error); res.status(500).json({ error: 'Failed to clear cache' }); } }); // Get all options for import fields router.get('/field-options', async (req, res) => { try { // Use cached connection const { connection } = await getDbConnection(); const cacheKey = 'field-options'; const result = await getCachedQuery(cacheKey, 'field-options', async () => { // Fetch companies (type 1) const [companies] = await connection.query(` SELECT cat_id, name FROM product_categories WHERE type = 1 ORDER BY name `); // Fetch artists (type 40) const [artists] = await connection.query(` SELECT cat_id, name FROM product_categories WHERE type = 40 ORDER BY name `); // Fetch sizes (type 50) const [sizes] = await connection.query(` SELECT cat_id, name FROM product_categories WHERE type = 50 ORDER BY name `); // Fetch themes with subthemes const [themes] = await connection.query(` SELECT t.cat_id, t.name AS display_name, t.type, t.name AS sort_theme, '' AS sort_subtheme, 1 AS level_order FROM product_categories t WHERE t.type = 20 UNION ALL SELECT ts.cat_id, CONCAT(t.name,' - ',ts.name) AS display_name, ts.type, t.name AS sort_theme, ts.name AS sort_subtheme, 2 AS level_order FROM product_categories ts JOIN product_categories t ON ts.master_cat_id = t.cat_id WHERE ts.type = 21 AND t.type = 20 ORDER BY sort_theme, sort_subtheme `); // Fetch categories with all levels const [categories] = await connection.query(` SELECT s.cat_id, s.name AS display_name, s.type, s.name AS sort_section, '' AS sort_category, '' AS sort_subcategory, '' AS sort_subsubcategory, 1 AS level_order FROM product_categories s WHERE s.type = 10 UNION ALL SELECT c.cat_id, CONCAT(s.name,' - ',c.name) AS display_name, c.type, s.name AS sort_section, c.name AS sort_category, '' AS sort_subcategory, '' AS sort_subsubcategory, 2 AS level_order FROM product_categories c JOIN product_categories s ON c.master_cat_id = s.cat_id WHERE c.type = 11 AND s.type = 10 UNION ALL SELECT sc.cat_id, CONCAT(s.name,' - ',c.name,' - ',sc.name) AS display_name, sc.type, s.name AS sort_section, c.name AS sort_category, sc.name AS sort_subcategory, '' AS sort_subsubcategory, 3 AS level_order FROM product_categories sc JOIN product_categories c ON sc.master_cat_id = c.cat_id JOIN product_categories s ON c.master_cat_id = s.cat_id WHERE sc.type = 12 AND c.type = 11 AND s.type = 10 UNION ALL SELECT ssc.cat_id, CONCAT(s.name,' - ',c.name,' - ',sc.name,' - ',ssc.name) AS display_name, ssc.type, s.name AS sort_section, c.name AS sort_category, sc.name AS sort_subcategory, ssc.name AS sort_subsubcategory, 4 AS level_order FROM product_categories ssc JOIN product_categories sc ON ssc.master_cat_id = sc.cat_id JOIN product_categories c ON sc.master_cat_id = c.cat_id JOIN product_categories s ON c.master_cat_id = s.cat_id WHERE ssc.type = 13 AND sc.type = 12 AND c.type = 11 AND s.type = 10 ORDER BY sort_section, sort_category, sort_subcategory, sort_subsubcategory `); // Fetch colors const [colors] = await connection.query(` SELECT color, name, hex_color FROM product_color_list ORDER BY \`order\` `); // Fetch suppliers const [suppliers] = await connection.query(` SELECT supplierid as value, companyname as label FROM suppliers WHERE companyname <> '' ORDER BY companyname `); // Fetch tax categories const [taxCategories] = await connection.query(` SELECT CAST(tax_code_id AS CHAR) as value, name as label FROM product_tax_codes ORDER BY tax_code_id = 0 DESC, name `); // Format and return all options return { companies: companies.map(c => ({ label: c.name, value: c.cat_id.toString() })), artists: artists.map(a => ({ label: a.name, value: a.cat_id.toString() })), sizes: sizes.map(s => ({ label: s.name, value: s.cat_id.toString() })), themes: themes.map(t => ({ label: t.display_name, value: t.cat_id.toString(), type: t.type, level: t.level_order })), categories: categories.map(c => ({ label: c.display_name, value: c.cat_id.toString(), type: c.type, level: c.level_order })), colors: colors.map(c => ({ label: c.name, value: c.color, hexColor: c.hex_color })), suppliers: suppliers, taxCategories: taxCategories, shippingRestrictions: [ { label: "None", value: "0" }, { label: "US Only", value: "1" }, { label: "Limited Quantity", value: "2" }, { label: "US/CA Only", value: "3" }, { label: "No FedEx 2 Day", value: "4" }, { label: "North America Only", value: "5" } ] }; }); // Add debugging to verify category types console.log(`Returning ${result.categories.length} categories with types: ${Array.from(new Set(result.categories.map(c => c.type))).join(', ')}`); res.json(result); } catch (error) { console.error('Error fetching import field options:', error); res.status(500).json({ error: 'Failed to fetch import field options' }); } }); // Get product lines for a specific company router.get('/product-lines/:companyId', async (req, res) => { try { // Use cached connection const { connection } = await getDbConnection(); const companyId = req.params.companyId; const cacheKey = `product-lines-${companyId}`; const lines = await getCachedQuery(cacheKey, 'product-lines', async () => { const [queryResult] = await connection.query(` SELECT cat_id as value, name as label FROM product_categories WHERE type = 2 AND master_cat_id = ? ORDER BY name `, [companyId]); return queryResult.map(l => ({ label: l.label, value: l.value.toString() })); }); res.json(lines); } catch (error) { console.error('Error fetching product lines:', error); res.status(500).json({ error: 'Failed to fetch product lines' }); } }); // Get sublines for a specific product line router.get('/sublines/:lineId', async (req, res) => { try { // Use cached connection const { connection } = await getDbConnection(); const lineId = req.params.lineId; const cacheKey = `sublines-${lineId}`; const sublines = await getCachedQuery(cacheKey, 'sublines', async () => { const [queryResult] = await connection.query(` SELECT cat_id as value, name as label FROM product_categories WHERE type = 3 AND master_cat_id = ? ORDER BY name `, [lineId]); return queryResult.map(s => ({ label: s.label, value: s.value.toString() })); }); res.json(sublines); } catch (error) { console.error('Error fetching sublines:', error); res.status(500).json({ error: 'Failed to fetch sublines' }); } }); // Add a simple endpoint to check file existence and permissions router.get('/check-file/:filename', (req, res) => { const { filename } = req.params; // Prevent directory traversal if (filename.includes('..') || filename.includes('/')) { return res.status(400).json({ error: 'Invalid filename' }); } // First check in products directory let filePath = path.join(uploadsDir, filename); let exists = fs.existsSync(filePath); // If not found in products, check in reusable directory if (!exists) { filePath = path.join(reusableUploadsDir, filename); exists = fs.existsSync(filePath); } try { // Check if file exists if (!exists) { return res.status(404).json({ error: 'File not found', path: filePath, exists: false, readable: false }); } // Check if file is readable fs.accessSync(filePath, fs.constants.R_OK); // Get file stats const stats = fs.statSync(filePath); return res.json({ filename, path: filePath, exists: true, readable: true, isFile: stats.isFile(), isDirectory: stats.isDirectory(), size: stats.size, created: stats.birthtime, modified: stats.mtime, permissions: stats.mode.toString(8) }); } catch (error) { return res.status(500).json({ error: error.message, path: filePath, exists: fs.existsSync(filePath), readable: false }); } }); // List all files in uploads directory router.get('/list-uploads', (req, res) => { try { const { directory = 'products' } = req.query; // Determine which directory to list let targetDir; if (directory === 'reusable') { targetDir = reusableUploadsDir; } else { targetDir = uploadsDir; // default to products } if (!fs.existsSync(targetDir)) { return res.status(404).json({ error: 'Uploads directory not found', path: targetDir }); } const files = fs.readdirSync(targetDir); const fileDetails = files.map(file => { const filePath = path.join(targetDir, file); try { const stats = fs.statSync(filePath); return { filename: file, isFile: stats.isFile(), isDirectory: stats.isDirectory(), size: stats.size, created: stats.birthtime, modified: stats.mtime, permissions: stats.mode.toString(8) }; } catch (error) { return { filename: file, error: error.message }; } }); return res.json({ directory: targetDir, type: directory, count: files.length, files: fileDetails }); } catch (error) { return res.status(500).json({ error: error.message }); } }); // Search products from production database router.get('/search-products', async (req, res) => { const { q, pid, company, dateRange } = req.query; if (!q && !pid) { return res.status(400).json({ error: 'Search term or pid is required' }); } try { const { connection } = await getDbConnection(); // Build WHERE clause with additional filters let whereClause; if (pid) { const pids = String(pid).split(',').map(Number).filter(n => !isNaN(n) && n > 0); if (pids.length === 0) { connection.release(); return res.status(400).json({ error: 'Invalid pid parameter' }); } if (pids.length === 1) { whereClause = `\n WHERE p.pid = ${connection.escape(pids[0])}`; } else { whereClause = `\n WHERE p.pid IN (${pids.map(p => connection.escape(p)).join(',')})`; } } else { whereClause = ` WHERE ( p.description LIKE ? OR p.itemnumber LIKE ? OR p.upc LIKE ? OR pc1.name LIKE ? OR s.companyname LIKE ? )`; } // Add company filter if provided if (company) { whereClause += ` AND p.company = ${connection.escape(company)}`; } // Add date range filter if provided if (dateRange) { let dateCondition; const now = new Date(); switch(dateRange) { case '1week': // Last week: date is after (current date - 7 days) const weekAgo = new Date(now); weekAgo.setDate(now.getDate() - 7); dateCondition = `p.datein >= ${connection.escape(weekAgo.toISOString().slice(0, 10))}`; break; case '1month': // Last month: date is after (current date - 30 days) const monthAgo = new Date(now); monthAgo.setDate(now.getDate() - 30); dateCondition = `p.datein >= ${connection.escape(monthAgo.toISOString().slice(0, 10))}`; break; case '2months': // Last 2 months: date is after (current date - 60 days) const twoMonthsAgo = new Date(now); twoMonthsAgo.setDate(now.getDate() - 60); dateCondition = `p.datein >= ${connection.escape(twoMonthsAgo.toISOString().slice(0, 10))}`; break; case '3months': // Last 3 months: date is after (current date - 90 days) const threeMonthsAgo = new Date(now); threeMonthsAgo.setDate(now.getDate() - 90); dateCondition = `p.datein >= ${connection.escape(threeMonthsAgo.toISOString().slice(0, 10))}`; break; case '6months': // Last 6 months: date is after (current date - 180 days) const sixMonthsAgo = new Date(now); sixMonthsAgo.setDate(now.getDate() - 180); dateCondition = `p.datein >= ${connection.escape(sixMonthsAgo.toISOString().slice(0, 10))}`; break; case '1year': // Last year: date is after (current date - 365 days) const yearAgo = new Date(now); yearAgo.setDate(now.getDate() - 365); dateCondition = `p.datein >= ${connection.escape(yearAgo.toISOString().slice(0, 10))}`; break; default: // If an unrecognized value is provided, don't add a date condition dateCondition = null; } if (dateCondition) { whereClause += ` AND ${dateCondition}`; } } const isPidSearch = !!pid; // Special case for wildcard search const isWildcardSearch = !isPidSearch && q === '*'; const searchPattern = isWildcardSearch ? '%' : `%${q}%`; const exactPattern = isWildcardSearch ? '%' : q; // Search for products based on various fields const query = ` SELECT p.pid, p.description AS title, p.notes AS description, p.itemnumber AS sku, p.upc AS barcode, p.harmonized_tariff_code, MIN(pcp.price_each) AS price, p.sellingprice AS regular_price, CASE WHEN sid.supplier_id = 92 THEN CASE WHEN COALESCE(sid.notions_cost_each, 0) > 0 THEN sid.notions_cost_each ELSE sid.supplier_cost_each END ELSE CASE WHEN COALESCE(sid.supplier_cost_each, 0) > 0 THEN sid.supplier_cost_each ELSE sid.notions_cost_each END END AS cost_price, s.companyname AS vendor, sid.supplier_itemnumber AS vendor_reference, sid.notions_itemnumber AS notions_reference, sid.supplier_id AS supplier, sid.notions_case_pack AS case_qty, pc1.name AS brand, p.company AS brand_id, pc2.name AS line, p.line AS line_id, pc3.name AS subline, p.subline AS subline_id, pc4.name AS artist, p.artist AS artist_id, COALESCE(CASE WHEN sid.supplier_id = 92 THEN sid.notions_qty_per_unit ELSE sid.supplier_qty_per_unit END, sid.notions_qty_per_unit) AS moq, p.weight, p.length, p.width, p.height, p.country_of_origin, ci.totalsold AS total_sold, p.datein AS first_received, 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 FROM products p 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 LEFT JOIN product_categories pc1 ON p.company = pc1.cat_id LEFT JOIN product_categories pc2 ON p.line = pc2.cat_id LEFT JOIN product_categories pc3 ON p.subline = pc3.cat_id LEFT JOIN product_categories pc4 ON p.artist = pc4.cat_id LEFT JOIN product_last_sold pls ON p.pid = pls.pid LEFT JOIN current_inventory ci ON p.pid = ci.pid ${whereClause} GROUP BY p.pid ${isPidSearch ? '' : isWildcardSearch ? 'ORDER BY p.datein DESC' : ` ORDER BY CASE WHEN p.description LIKE ? THEN 1 WHEN p.itemnumber = ? THEN 2 WHEN p.upc = ? THEN 3 WHEN pc1.name LIKE ? THEN 4 WHEN s.companyname LIKE ? THEN 5 ELSE 6 END `} ${isPidSearch ? '' : 'LIMIT 100'} `; // Prepare query parameters based on search type let queryParams; if (isPidSearch) { queryParams = []; } else if (isWildcardSearch) { queryParams = [ searchPattern, // LIKE for description searchPattern, // LIKE for itemnumber searchPattern, // LIKE for upc searchPattern, // LIKE for brand name searchPattern // LIKE for company name ]; } else { queryParams = [ searchPattern, // LIKE for description searchPattern, // LIKE for itemnumber searchPattern, // LIKE for upc searchPattern, // LIKE for brand name searchPattern, // LIKE for company name // For ORDER BY clause searchPattern, // LIKE for description exactPattern, // Exact match for itemnumber exactPattern, // Exact match for upc searchPattern, // LIKE for brand name searchPattern // LIKE for company name ]; } const [results] = await connection.query(query, queryParams); // Debug log to check values if (results.length > 0) { console.log('Product search result sample fields:', { pid: results[0].pid, tax_code: results[0].tax_code, tax_code_type: typeof results[0].tax_code, tax_code_value: `Value: '${results[0].tax_code}'`, size_cat: results[0].size_cat, shipping_restrictions: results[0].shipping_restrictions, supplier: results[0].supplier, case_qty: results[0].case_qty, moq: results[0].moq }); } res.json(results); } catch (error) { console.error('Error searching products:', error); res.status(500).json({ error: 'Failed to search products' }); } }); // Shared SELECT for product queries (matches search-products fields) const PRODUCT_SELECT = ` SELECT p.pid, p.description AS title, p.notes AS description, p.itemnumber AS sku, p.upc AS barcode, p.harmonized_tariff_code, MIN(pcp.price_each) AS price, p.sellingprice AS regular_price, CASE WHEN sid.supplier_id = 92 THEN CASE WHEN COALESCE(sid.notions_cost_each, 0) > 0 THEN sid.notions_cost_each ELSE sid.supplier_cost_each END ELSE CASE WHEN COALESCE(sid.supplier_cost_each, 0) > 0 THEN sid.supplier_cost_each ELSE sid.notions_cost_each END END AS cost_price, s.companyname AS vendor, sid.supplier_itemnumber AS vendor_reference, sid.notions_itemnumber AS notions_reference, sid.supplier_id AS supplier, sid.notions_case_pack AS case_qty, pc1.name AS brand, p.company AS brand_id, pc2.name AS line, p.line AS line_id, pc3.name AS subline, p.subline AS subline_id, pc4.name AS artist, p.artist AS artist_id, COALESCE(CASE WHEN sid.supplier_id = 92 THEN sid.notions_qty_per_unit ELSE sid.supplier_qty_per_unit END, sid.notions_qty_per_unit) AS moq, p.weight, p.length, p.width, p.height, p.country_of_origin, ci.totalsold AS total_sold, p.datein AS first_received, 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, 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 LEFT JOIN product_categories pc1 ON p.company = pc1.cat_id LEFT JOIN product_categories pc2 ON p.line = pc2.cat_id LEFT JOIN product_categories pc3 ON p.subline = pc3.cat_id LEFT JOIN product_categories pc4 ON p.artist = pc4.cat_id LEFT JOIN product_last_sold pls ON p.pid = pls.pid LEFT JOIN current_inventory ci ON p.pid = ci.pid`; // Load products for a specific line (company + line + optional subline) router.get('/line-products', async (req, res) => { const { company, line, subline } = req.query; if (!company || !line) { return res.status(400).json({ error: 'company and line are required' }); } try { const { connection } = await getDbConnection(); let where = 'WHERE p.company = ? AND p.line = ?'; const params = [Number(company), Number(line)]; if (subline) { where += ' AND p.subline = ?'; params.push(Number(subline)); } 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) { console.error('Error loading line products:', error); res.status(500).json({ error: 'Failed to load line products' }); } }); // Load new products (last 45 days by release date, excluding preorders) router.get('/new-products', async (req, res) => { try { const { connection } = await getDbConnection(); const query = `${PRODUCT_SELECT} LEFT JOIN shop_inventory si2 ON p.pid = si2.pid AND si2.store = 0 WHERE DATEDIFF(NOW(), p.date_ol) <= 45 AND p.notnew = 0 AND (si2.all IS NULL OR si2.all != 2) GROUP BY p.pid ORDER BY IF(p.date_ol != '0000-00-00', p.date_ol, p.date_created) DESC`; const [results] = await connection.query(query); res.json(results); } catch (error) { console.error('Error loading new products:', error); res.status(500).json({ error: 'Failed to load new products' }); } }); // Load preorder products router.get('/preorder-products', async (req, res) => { try { const { connection } = await getDbConnection(); const query = `${PRODUCT_SELECT} LEFT JOIN shop_inventory si2 ON p.pid = si2.pid AND si2.store = 0 WHERE si2.all = 2 GROUP BY p.pid ORDER BY IF(p.date_ol != '0000-00-00', p.date_ol, p.date_created) DESC`; const [results] = await connection.query(query); res.json(results); } catch (error) { console.error('Error loading preorder products:', error); res.status(500).json({ error: 'Failed to load preorder products' }); } }); // Load hidden recently-created products from local PG, enriched from MySQL router.get('/hidden-new-products', async (req, res) => { try { const pool = req.app.locals.pool; const pgResult = await pool.query( `SELECT pid FROM products WHERE visible = false AND created_at > NOW() - INTERVAL '90 days' ORDER BY created_at DESC LIMIT 500` ); const pids = pgResult.rows.map(r => r.pid); if (pids.length === 0) return res.json([]); const { connection } = await getDbConnection(); const placeholders = pids.map(() => '?').join(','); const query = `${PRODUCT_SELECT} WHERE p.pid IN (${placeholders}) GROUP BY p.pid ORDER BY FIELD(p.pid, ${placeholders})`; const [results] = await connection.query(query, [...pids, ...pids]); res.json(results); } catch (error) { console.error('Error loading hidden new products:', error); res.status(500).json({ error: 'Failed to load hidden new products' }); } }); // Load landing page extras (featured lines) for new/preorder pages router.get('/landing-extras', async (req, res) => { const { catId, sid } = req.query; if (!catId) { return res.status(400).json({ error: 'catId is required' }); } try { const { connection } = await getDbConnection(); const [results] = await connection.query( `SELECT extra_id, image, extra_cat_id, path, name, top_text, is_new FROM product_category_landing_extras WHERE cat_id = ? AND sid = ? AND section_cat_id = 0 AND hidden = 0 ORDER BY \`order\` DESC, name ASC`, [Number(catId), Number(sid) || 0] ); res.json(results); } catch (error) { console.error('Error loading landing extras:', error); res.status(500).json({ error: 'Failed to load landing extras' }); } }); // Load products by shop path (resolves category names to IDs) router.get('/path-products', async (req, res) => { res.set('Cache-Control', 'no-store'); const { path: shopPath } = req.query; if (!shopPath) { return res.status(400).json({ error: 'path is required' }); } try { const { connection } = await getDbConnection(); // Strip common URL prefixes (full URLs, /shop/, leading slash) const cleanPath = String(shopPath) .replace(/^https?:\/\/[^/]+/, '') .replace(/^\/shop\//, '/') .replace(/^\//, ''); const parts = cleanPath.split('/'); const filters = {}; for (let i = 0; i < parts.length - 1; i += 2) { filters[parts[i]] = decodeURIComponent(parts[i + 1]).replace(/_/g, ' '); } if (Object.keys(filters).length === 0) { return res.status(400).json({ error: 'No valid filters found in path' }); } // Resolve category names to IDs (order matters: company -> line -> subline) const typeMap = { company: 1, line: 2, subline: 3, section: 10, cat: 11, subcat: 12, subsubcat: 13 }; const resolvedIds = {}; const resolveOrder = ['company', 'line', 'subline', 'section', 'cat', 'subcat', 'subsubcat']; for (const key of resolveOrder) { const value = filters[key]; if (!value) continue; const type = typeMap[key]; if (!type) continue; const types = key === 'cat' ? [11, 20] : key === 'subcat' ? [12, 21] : [type]; // For line/subline, filter by parent (master_cat_id) to disambiguate let parentFilter = ''; const qParams = [value]; if (key === 'line' && resolvedIds.company != null) { parentFilter = ' AND master_cat_id = ?'; qParams.push(resolvedIds.company); } else if (key === 'subline' && resolvedIds.line != null) { parentFilter = ' AND master_cat_id = ?'; qParams.push(resolvedIds.line); } const [rows] = await connection.query( `SELECT cat_id FROM product_categories WHERE LOWER(name) = LOWER(?) AND type IN (${types.join(',')})${parentFilter} LIMIT 1`, qParams ); if (rows.length > 0) { resolvedIds[key] = rows[0].cat_id; } else { return res.json([]); } } // Build WHERE using resolved IDs const whereParts = []; const params = []; const directFields = { company: 'p.company', line: 'p.line', subline: 'p.subline' }; for (const [key, catId] of Object.entries(resolvedIds)) { if (directFields[key]) { whereParts.push(`${directFields[key]} = ?`); params.push(catId); } else { whereParts.push('EXISTS (SELECT 1 FROM product_category_index pci2 WHERE pci2.pid = p.pid AND pci2.cat_id = ?)'); params.push(catId); } } if (whereParts.length === 0) { 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 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) { console.error('Error loading path products:', error); res.status(500).json({ error: 'Failed to load products by path' }); } }); // Get product images for a given PID from production DB router.get('/product-images/:pid', async (req, res) => { const pid = parseInt(req.params.pid, 10); if (!pid || pid <= 0) { return res.status(400).json({ error: 'Valid PID is required' }); } try { const { connection } = await getDbConnection(); const [rows] = await connection.query( 'SELECT iid, type, width, height, `order`, hidden FROM product_images WHERE pid = ? ORDER BY `order` DESC, type', [pid] ); // Group by iid and build image URLs using the same logic as the PHP codebase const typeMap = { 1: 'o', 2: 'l', 3: 't', 4: '100x100', 5: '175x175', 6: '300x300', 7: '600x600', 8: '500x500', 9: '150x150' }; const padded = String(pid).padStart(10, '0'); const pathPrefix = `${padded.substring(0, 4)}/${padded.substring(4, 7)}/`; const imagesByIid = {}; for (const row of rows) { const typeName = typeMap[row.type]; if (!typeName) continue; if (!imagesByIid[row.iid]) { imagesByIid[row.iid] = { iid: row.iid, order: row.order, hidden: !!row.hidden, sizes: {} }; } imagesByIid[row.iid].sizes[typeName] = { width: row.width, height: row.height, url: `https://sbing.com/i/products/${pathPrefix}${pid}-${typeName}-${row.iid}.jpg`, }; } const images = Object.values(imagesByIid).sort((a, b) => b.order - a.order); res.json(images); } catch (error) { console.error('Error fetching product images:', error); res.status(500).json({ error: 'Failed to fetch product images' }); } }); // 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 function buildSupplierPrefix(supplierId) { const numericId = Number.parseInt(String(supplierId), 10); if (Number.isNaN(numericId) || numericId < 0) { return null; } const padded = String(numericId).padStart(5, '0'); const prefix = `${UPC_SUPPLIER_PREFIX_LEADING_DIGIT}${padded}`; return prefix.length === 6 ? prefix : null; } function calculateUpcCheckDigit(upcWithoutCheckDigit) { if (!/^\d{11}$/.test(upcWithoutCheckDigit)) { throw new Error('UPC body must be 11 numeric characters'); } let sum = 0; for (let i = 0; i < upcWithoutCheckDigit.length; i += 1) { const digit = Number.parseInt(upcWithoutCheckDigit[i], 10); sum += (i % 2 === 0) ? digit * 3 : digit; } const mod = sum % 10; return mod === 0 ? 0 : 10 - mod; } const upcReservationCache = new Map(); const upcGenerationLocks = new Map(); function getReservedSequence(prefix) { const entry = upcReservationCache.get(prefix); if (!entry) { return 0; } if (Date.now() > entry.expiresAt) { upcReservationCache.delete(prefix); return 0; } return entry.lastSequence; } function setReservedSequence(prefix, sequence) { upcReservationCache.set(prefix, { lastSequence: sequence, expiresAt: Date.now() + UPC_RESERVATION_TTL }); } async function runWithSupplierLock(prefix, task) { const previous = upcGenerationLocks.get(prefix) || Promise.resolve(); const chained = previous.catch(() => {}).then(() => task()); upcGenerationLocks.set(prefix, chained); try { return await chained; } finally { if (upcGenerationLocks.get(prefix) === chained) { upcGenerationLocks.delete(prefix); } } } router.post('/generate-upc', async (req, res) => { const { supplierId, increment } = req.body || {}; if (supplierId === undefined || supplierId === null || String(supplierId).trim() === '') { return res.status(400).json({ error: 'Supplier ID is required to generate a UPC' }); } const supplierPrefix = buildSupplierPrefix(supplierId); if (!supplierPrefix) { return res.status(400).json({ error: 'Supplier ID must be a non-negative number with at most 5 digits' }); } const step = Number.parseInt(increment, 10); const sequenceIncrement = Number.isNaN(step) || step < 1 ? 1 : step; try { const result = await runWithSupplierLock(supplierPrefix, async () => { const { connection } = await getDbConnection(); const [rows] = await connection.query( `SELECT CAST(SUBSTRING(upc,7,5) AS UNSIGNED) AS num FROM products WHERE LEFT(upc, 6) = ? AND LENGTH(upc) = 12 ORDER BY num DESC LIMIT 1`, [supplierPrefix] ); const lastSequenceFromDb = rows && rows.length > 0 && rows[0].num !== null ? Number.parseInt(rows[0].num, 10) || 0 : 0; const cachedSequence = getReservedSequence(supplierPrefix); const baselineSequence = Math.max(lastSequenceFromDb, cachedSequence); let nextSequence = baselineSequence + sequenceIncrement; let candidateUpc = null; let attempts = 0; while (attempts < 10 && nextSequence <= UPC_MAX_SEQUENCE) { const sequencePart = String(nextSequence).padStart(5, '0'); const upcBody = `${supplierPrefix}${sequencePart}`; const checkDigit = calculateUpcCheckDigit(upcBody); const fullUpc = `${upcBody}${checkDigit}`; const [existing] = await connection.query( 'SELECT 1 FROM products WHERE upc = ? LIMIT 1', [fullUpc] ); if (!existing || existing.length === 0) { candidateUpc = { upc: fullUpc, sequence: nextSequence }; break; } nextSequence += 1; attempts += 1; } if (!candidateUpc) { const reason = nextSequence > UPC_MAX_SEQUENCE ? 'UPC range exhausted for this supplier' : 'Unable to find an available UPC'; const error = new Error(reason); error.status = 409; throw error; } setReservedSequence(supplierPrefix, candidateUpc.sequence); return candidateUpc.upc; }); return res.json({ success: true, upc: result }); } catch (error) { console.error('Error generating UPC:', error); const status = error.status && Number.isInteger(error.status) ? error.status : 500; const message = status === 500 ? 'Failed to generate UPC' : error.message; return res.status(status).json({ error: message, details: status === 500 ? error.message : undefined }); } }); // Bulk "does this UPC already exist" check for the import validator. // The one-at-a-time alternative is /search-products?q=, but that runs a // five-column LIKE '%q%' scan across ten joined tables (~6s each) and every call // queues on the single shared connection from getDbConnection() — a 20-product // batch takes minutes. This answers the whole batch with one indexed IN() lookup // on idx_upc. Matches /check-upc-and-generate-sku's semantics: `upc` only, not upc_2. const MAX_UPCS_PER_CHECK = 500; router.get('/check-upcs', async (req, res) => { const { upcs } = req.query; if (!upcs) { return res.status(400).json({ error: 'upcs query parameter is required' }); } // Dedupe and keep only well-formed barcodes — anything else can't match the // column anyway, and dropping it keeps the IN() list tight. const upcList = [...new Set( String(upcs).split(',').map(s => s.trim()).filter(s => /^\d{8,14}$/.test(s)) )]; if (upcList.length === 0) { return res.json({ found: {}, checked: 0 }); } if (upcList.length > MAX_UPCS_PER_CHECK) { return res.status(400).json({ error: `Too many UPCs (${upcList.length}); send at most ${MAX_UPCS_PER_CHECK} per request` }); } try { const { connection } = await getDbConnection(); const placeholders = upcList.map(() => '?').join(','); const [rows] = await connection.query( `SELECT pid, upc, itemnumber, description FROM products WHERE upc IN (${placeholders})`, upcList ); // idx_upc is non-unique, so a UPC can legitimately hit more than one product. // Report the lowest pid (the original) and say how many share it. const found = {}; for (const row of rows) { const key = String(row.upc); const existing = found[key]; if (!existing) { found[key] = { pid: row.pid, itemNumber: row.itemnumber, title: row.description, matchCount: 1 }; continue; } existing.matchCount += 1; if (row.pid < existing.pid) { existing.pid = row.pid; existing.itemNumber = row.itemnumber; existing.title = row.description; } } return res.json({ found, checked: upcList.length }); } catch (error) { console.error('Error checking UPCs:', error); return res.status(500).json({ error: 'Failed to check UPCs', details: error.message }); } }); // Endpoint to check UPC and generate item number router.get('/check-upc-and-generate-sku', async (req, res) => { const { upc, supplierId } = req.query; if (!upc || !supplierId) { return res.status(400).json({ error: 'UPC and supplier ID are required' }); } try { const { connection } = await getDbConnection(); // Step 1: Check if the UPC already exists const [upcCheck] = await connection.query( 'SELECT pid, itemnumber FROM products WHERE upc = ? LIMIT 1', [upc] ); if (upcCheck.length > 0) { return res.status(409).json({ error: 'A product with this UPC already exists', existingProductId: upcCheck[0].pid, existingItemNumber: upcCheck[0].itemnumber }); } // Step 2: Generate item number - supplierId-last5DigitsOfUPC minus last digit let itemNumber = ''; const upcStr = String(upc); // Extract the last 5 digits of the UPC, removing the last digit (checksum) // So we get 5 digits from positions: length-6 to length-2 if (upcStr.length >= 6) { const lastFiveMinusOne = upcStr.substring(upcStr.length - 6, upcStr.length - 1); itemNumber = `${supplierId}-${lastFiveMinusOne}`; } else if (upcStr.length >= 5) { // If UPC is shorter, use as many digits as possible const digitsToUse = upcStr.substring(0, upcStr.length - 1); itemNumber = `${supplierId}-${digitsToUse}`; } else { // Very short UPC, just use the whole thing itemNumber = `${supplierId}-${upcStr}`; } // Step 3: Check if the generated item number exists const [itemNumberCheck] = await connection.query( 'SELECT pid FROM products WHERE itemnumber = ? LIMIT 1', [itemNumber] ); // Step 4: If the item number exists, modify it to use the last 5 digits of the UPC if (itemNumberCheck.length > 0) { console.log(`Item number ${itemNumber} already exists, using alternative format`); if (upcStr.length >= 5) { // Use the last 5 digits (including the checksum) const lastFive = upcStr.substring(upcStr.length - 5); itemNumber = `${supplierId}-${lastFive}`; // Check again if this new item number also exists const [altItemNumberCheck] = await connection.query( 'SELECT pid FROM products WHERE itemnumber = ? LIMIT 1', [itemNumber] ); if (altItemNumberCheck.length > 0) { // If even the alternative format exists, add a timestamp suffix for uniqueness const timestamp = Date.now().toString().substring(8, 13); // Get last 5 digits of timestamp itemNumber = `${supplierId}-${timestamp}`; console.log(`Alternative item number also exists, using timestamp: ${itemNumber}`); } } else { // For very short UPCs, add a timestamp const timestamp = Date.now().toString().substring(8, 13); // Get last 5 digits of timestamp itemNumber = `${supplierId}-${timestamp}`; } } // Return the generated item number res.json({ success: true, itemNumber, upc, supplierId }); } catch (error) { console.error('Error checking UPC and generating item number:', error); res.status(500).json({ error: 'Failed to check UPC and generate item number', details: error.message }); } }); // Get product categories for a specific product router.get('/product-categories/:pid', async (req, res) => { try { const { pid } = req.params; if (!pid || isNaN(parseInt(pid))) { return res.status(400).json({ error: 'Valid product ID is required' }); } // Use the getDbConnection function instead of getPool const { connection } = await getDbConnection(); // Query to get categories for a specific product const query = ` SELECT pc.cat_id, pc.name, pc.type, pc.combined_name, pc.master_cat_id FROM product_category_index pci JOIN product_categories pc ON pci.cat_id = pc.cat_id WHERE pci.pid = ? ORDER BY pc.type, pc.name `; const [rows] = await connection.query(query, [pid]); // Add debugging to log category types const categoryTypes = rows.map(row => row.type); const uniqueTypes = [...new Set(categoryTypes)]; console.log(`Product ${pid} has ${rows.length} categories with types: ${uniqueTypes.join(', ')}`); console.log('Categories:', rows.map(row => ({ id: row.cat_id, name: row.name, type: row.type }))); // Check for parent categories to filter out deals and black friday const sectionQuery = ` SELECT pc.cat_id, pc.name FROM product_categories pc WHERE pc.type = 10 AND (LOWER(pc.name) LIKE '%deal%' OR LOWER(pc.name) LIKE '%black friday%') `; const [dealSections] = await connection.query(sectionQuery); const dealSectionIds = dealSections.map(section => section.cat_id); console.log('Filtering out categories from deal sections:', dealSectionIds); // Filter out categories from deals and black friday sections const filteredCategories = rows.filter(category => { // Direct check for top-level deal sections if (category.type === 10) { return !dealSectionIds.some(id => id === category.cat_id); } // For categories (type 11), check if their parent is a deal section if (category.type === 11) { return !dealSectionIds.some(id => id === category.master_cat_id); } // For subcategories (type 12), get their parent category first if (category.type === 12) { const parentId = category.master_cat_id; // Find the parent category in our rows const parentCategory = rows.find(c => c.cat_id === parentId); // If parent not found or parent's parent is not a deal section, keep it return !parentCategory || !dealSectionIds.some(id => id === parentCategory.master_cat_id); } // For subsubcategories (type 13), check their hierarchy manually if (category.type === 13) { const parentId = category.master_cat_id; // Find the parent subcategory const parentSubcategory = rows.find(c => c.cat_id === parentId); if (!parentSubcategory) return true; // Find the grandparent category const grandparentId = parentSubcategory.master_cat_id; const grandparentCategory = rows.find(c => c.cat_id === grandparentId); // If grandparent not found or grandparent's parent is not a deal section, keep it return !grandparentCategory || !dealSectionIds.some(id => id === grandparentCategory.master_cat_id); } // Keep all other category types return true; }); console.log(`Filtered out ${rows.length - filteredCategories.length} deal/black friday categories`); // Format the response to match the expected format in the frontend const categories = filteredCategories.map(category => ({ value: category.cat_id.toString(), label: category.name, type: category.type, combined_name: category.combined_name })); res.json(categories); } catch (error) { console.error('Error fetching product categories:', error); res.status(500).json({ error: 'Failed to fetch product categories', details: error.message }); } }); // Build a PIDs-from-query SQL using product_query_filter rows (mirrors productquery.class.php logic) // NOTE: PRODUCT_SELECT uses aliases: products→p, current_inventory→ci, supplier_item_data→sid // All filter conditions must use those aliases. Extra JOINs (category index, etc.) are appended. // // Constants sourced from registry.class.php / product_category.class.php (ACOT store id confirmed by user): // ACOT_STORE=0, SRC_ACOT=10, SRC_PREORDER=11, SRC_NOTIONS=13 // CAT types: section=10, cat=11, subcat=12, subsubcat=13, theme=20, subtheme=21, digitheme=30 function buildQueryFilterSql(filters) { // filterGroups: Map // ororor=true means this group is OR-connected to the previous group (PHP filter_or mechanism) const filterGroups = new Map(); const joinTables = new Map(); // alias -> JOIN clause const unsupported = []; function addGroup(key, condition, isNot = false, ororor = false) { if (!filterGroups.has(key)) filterGroups.set(key, { conditions: [], isNot, ororor }); filterGroups.get(key).conditions.push(condition); } function addJoin(alias, clause) { if (!joinTables.has(alias)) joinTables.set(alias, clause); } // INNER JOIN on product_category_index (product must have this category to appear) function ciJoin(alias) { addJoin(alias, `JOIN product_category_index AS ${alias} ON (p.pid=${alias}.pid)`); } // Translate operator string to SQL operator function toSqlOp(op) { const map = { equals: '=', notequals: '<>', greater: '>', greater_equals: '>=', less: '<', less_equals: '<=', between: ' BETWEEN ', contains: ' LIKE ', notcontains: ' NOT LIKE ', begins: ' LIKE ', true: '<>0', true1: '=1', false: '=0', isnull: ' IS NULL', }; return map[op] || '='; } // Proper MySQL string escaping (mirrors mysql_real_escape_string order) function strVal(v) { return String(v) .replace(/\\/g, '\\\\') .replace(/\0/g, '\\0') .replace(/\n/g, '\\n') .replace(/\r/g, '\\r') .replace(/'/g, "\\'") .replace(/\x1a/g, '\\Z'); } // URL-decode then escape — mirrors PHP: safefor_query(urldecode($row['filter_text1'])) function decode(v) { if (!v) return ''; try { return decodeURIComponent(String(v)); } catch { return String(v); } } // Numeric field with BETWEEN support (appends AND t2 when operator is BETWEEN) function numFilt(field, sqlOp, t1, t2) { if (sqlOp.trim() === 'BETWEEN' && t2) return `${field} BETWEEN ${parseFloat(t1) || 0} AND ${parseFloat(t2) || 0}`; return `${field}${sqlOp}${parseFloat(t1) || 0}`; } // Date/string field with BETWEEN support function dateFilt(field, sqlOp, t1, t2) { if (sqlOp.trim() === 'BETWEEN' && t2) return `${field} BETWEEN '${strVal(t1)}' AND '${strVal(t2)}'`; return `${field}${sqlOp}'${strVal(t1)}'`; } // Store/source constants (registry.class.php; ACOT store id confirmed by user) const ACOT_STORE = 0; const SRC_ACOT = 10; const SRC_PREORDER = 11; const SRC_NOTIONS = 13; // Category type constants (product_category.class.php) const CAT_THEMES = '20,21'; const CAT_CATEGORIES_NO_SECTION = '11,12,13'; for (const row of filters) { const sqlOp = toSqlOp(row.filter_operator); const isNot = sqlOp === '<>' || row.filter_operator === 'notequals' || row.filter_operator === 'notcontains'; const t1 = decode(row.filter_text1); const t2 = decode(row.filter_text2); const filterOr = Boolean(row.filter_or); switch (row.filter_type) { // ── products table (aliased as p) ────────────────────────────────────── case 'company': if (t1 && t2) { const filt = `(p.company=${parseFloat(t1)||0} AND p.line=${parseFloat(t2)||0})`; addGroup('company' + sqlOp, isNot ? `NOT ${filt}` : filt, isNot); } else { addGroup('company' + sqlOp, `p.company${sqlOp}${parseFloat(t1)||0}`, isNot); } break; case 'line': addGroup('line' + sqlOp, numFilt('p.line', sqlOp, t1, t2), isNot); break; case 'subline': addGroup('subline' + sqlOp, numFilt('p.subline', sqlOp, t1, t2), isNot); break; case 'no_company': addGroup('no_company', 'p.company=0'); break; case 'no_line': addGroup('no_line', 'p.line=0'); break; case 'no_subline': addGroup('no_subline', 'p.subline=0'); break; case 'artist': addGroup('artist' + sqlOp, numFilt('p.artist', sqlOp, t1, t2), isNot); break; case 'size_cat': addGroup('size_cat' + sqlOp, numFilt('p.size_cat', sqlOp, t1, t2), isNot); break; case 'dimension': addGroup('dimension' + sqlOp, numFilt('p.dimension', sqlOp, t1, t2), isNot); break; case 'yarn_weight': addGroup('yarn_weight' + sqlOp, numFilt('p.yarn_weight', sqlOp, t1, t2), isNot); break; case 'material': addGroup('material' + sqlOp, numFilt('p.material', sqlOp, t1, t2), isNot); break; case 'weight': addGroup('weight' + sqlOp, numFilt('p.weight', sqlOp, t1, t2)); break; case 'weight_price_ratio': addGroup('weight' + sqlOp, numFilt('p.weight/p.price_for_sort', sqlOp, t1, t2)); break; case 'price_weight_ratio': addGroup('weight' + sqlOp, numFilt('p.price_for_sort/p.weight', sqlOp, t1, t2)); break; case 'length': addGroup('length' + sqlOp, numFilt('p.length', sqlOp, t1, t2)); break; case 'width': addGroup('width' + sqlOp, numFilt('p.width', sqlOp, t1, t2)); break; case 'height': addGroup('height' + sqlOp, numFilt('p.height', sqlOp, t1, t2)); break; case 'no_dim': addGroup('no_dim', 'p.length=0 AND p.width=0 AND p.height=0'); break; case 'hide': addGroup('hide', `p.hide${sqlOp}`); break; case 'hide_in_shop':addGroup('hide_in_shop', `p.hide_in_shop${sqlOp}`); break; case 'discontinued':addGroup('discontinued', `p.discontinued${sqlOp}`); break; case 'force_flag': addGroup('force_flag', `p.force_flag${sqlOp}`); break; case 'exclusive': addGroup('exclusive', `p.exclusive${sqlOp}`); break; case 'lock_quantity': addGroup('lock_quantity', `p.lock_qty${sqlOp}`); break; case 'show_notify': addGroup('show_notify', `p.show_notify${sqlOp}`); break; case 'downloadable':addGroup('downloadable', `p.downloadable${sqlOp}`); break; case 'usa_only': addGroup('usa_only', `p.usa_only${sqlOp}`); break; case 'not_clearance': addGroup('not_clearance', `p.not_clearance${sqlOp}`); break; case 'stat_stop': addGroup('stat_stop', `p.stat_stop${sqlOp}`); break; case 'notnew': addGroup('notnew', `p.notnew${sqlOp}`); break; case 'not_backinstock': addGroup('not_backinstock', `p.not_backinstock${sqlOp}`); break; case 'reorder': addGroup('reorder', `p.reorder${sqlOp}${parseFloat(t1)||0}`); break; case 'score': addGroup('score' + sqlOp, numFilt('p.score', sqlOp, t1, t2)); break; case 'sold_view_score': addGroup('sold_view_score' + sqlOp, numFilt('p.sold_view_score', sqlOp, t1, t2)); break; case 'visibility_score': addGroup('visibility_score' + sqlOp, numFilt('p.visibility_score', sqlOp, t1, t2)); break; case 'health_score': addGroup('health_score' + sqlOp, `p.health_score${sqlOp}'${strVal(t1)}'`); break; case 'tax_code': addGroup('tax_code', `p.tax_code${sqlOp}${parseFloat(t1)||0}`); break; case 'investor': addGroup('investor' + sqlOp, `p.investorid${sqlOp}${parseFloat(t1)||0}`, isNot); break; case 'price': case 'default_price': addGroup('default_price' + sqlOp, numFilt('p.sellingprice', sqlOp, t1, t2), isNot); break; case 'price_for_sort': addGroup('price_for_sort' + sqlOp, numFilt('p.price_for_sort', sqlOp, t1, t2)); break; case 'salepercent_for_sort': case 'salepercent_for_sort__clearance': { // PHP divides by 100 if value > 1 (percentages stored as decimals) const v1 = (parseFloat(t1)||0) > 1 ? (parseFloat(t1)/100) : (parseFloat(t1)||0); const v2 = t2 ? ((parseFloat(t2)||0) > 1 ? (parseFloat(t2)/100) : (parseFloat(t2)||0)) : null; const filt = (sqlOp.trim() === 'BETWEEN' && v2 != null) ? `p.salepercent_for_sort BETWEEN ${v1} AND ${v2}` : `p.salepercent_for_sort${sqlOp}${v1}`; addGroup(row.filter_type + sqlOp, filt); break; } case 'is_clearance': addGroup('is_clearance' + sqlOp, `(p.clearance_date != '0000-00-00 00:00:00')${sqlOp}`); break; case 'msrp': addGroup('msrp' + sqlOp, numFilt('p.msrp', sqlOp, t1, t2)); break; case 'default_less_msrp':addGroup('default_less_msrp', '(p.sellingprice < p.msrp)'); break; case 'default_more_msrp':addGroup('default_more_msrp', '(p.sellingprice > p.msrp)'); break; case 'minimum_advertised_price': addGroup('map', numFilt('p.minimum_advertised_price', sqlOp, t1, t2)); break; case 'minimum_advertised_price_error': addGroup('map_error', row.filter_operator !== 'false' ? 'p.minimum_advertised_price > p.sellingprice' : 'p.minimum_advertised_price <= p.sellingprice'); break; case 'wholesale_discount': { const v1 = (parseFloat(t1)||0) > 1 ? (parseFloat(t1)/100) : (parseFloat(t1)||0); const v2 = t2 ? ((parseFloat(t2)||0) > 1 ? (parseFloat(t2)/100) : (parseFloat(t2)||0)) : null; const filt = (sqlOp.trim() === 'BETWEEN' && v2 != null) ? `p.wholesale_discount BETWEEN ${v1} AND ${v2}` : `p.wholesale_discount${sqlOp}${v1}`; addGroup('wholesale_discount' + sqlOp, filt); break; } case 'wholesale_unit_qty': addGroup('wholesale_unit_qty' + sqlOp, numFilt('p.wholesale_unit_qty', sqlOp, t1, t2)); break; case 'points_multiplier': addGroup('points_multiplier' + sqlOp, numFilt('p.points_multiplier', sqlOp, t1, t2)); break; case 'points_bonus': addGroup('points_bonus' + sqlOp, numFilt('p.points_bonus', sqlOp, t1, t2)); break; case 'points_extra': addGroup('points_extra', '(p.points_multiplier>.5 OR p.points_bonus>0)'); break; case 'auto_pricing_allowed': addGroup('auto_pricing', 'p.price_lock=0'); break; case 'auto_pricing_disallowed': addGroup('auto_pricing', 'p.price_lock=1'); break; case 'handling_fee': addGroup('handling_fee' + sqlOp, numFilt('p.handling_fee', sqlOp, t1, t2)); break; case 'ship_tier': addGroup('ship_tier' + sqlOp, numFilt('p.shipping_tier', sqlOp, t1, t2)); break; case 'shipping_restrictions': addGroup('ship_tier' + sqlOp, `p.shipping_restrictions${sqlOp}${parseFloat(t1)||0}`); break; case 'min_qty_wanted': addGroup('min_qty_wanted', numFilt('p.min_qty_wanted', sqlOp, t1, t2)); break; case 'qty_bundled': addGroup('min_qty_wanted', numFilt('p.qty_bundled', sqlOp, t1, t2)); break; case 'no_40_percent_promo': addGroup('no_40_percent_promo', `p.no_40_percent_promo${sqlOp}`); break; case 'exclude_google_feed': addGroup('exclude_google_feed', `p.exclude_google_feed${sqlOp}`); break; case 'store': { const bit = Math.pow(2, parseInt(t1) || 0); addGroup('store_' + (parseInt(t1)||0), `p.store & ${bit}${sqlOp}${bit}`); break; } case 'no_store': addGroup('store_' + (parseInt(t1)||0), 'p.store=0'); break; case 'location': { let filt = `p.aisle${sqlOp}'${strVal(t1)}'`; if (t2) filt += ` AND p.rack${sqlOp}'${strVal(t2)}'`; addGroup('location' + sqlOp, filt); break; } case 'name': addGroup('name' + sqlOp, (row.filter_operator === 'contains' || row.filter_operator === 'notcontains') ? `p.description${sqlOp}'%${strVal(t1)}%'` : `p.description${sqlOp}'${strVal(t1)}'`, isNot); break; case 'short_description': addGroup('short_description' + sqlOp, (row.filter_operator === 'contains' || row.filter_operator === 'notcontains') ? `p.description_short${sqlOp}'%${strVal(t1)}%'` : `p.description_short${sqlOp}'${strVal(t1)}'`, isNot); break; case 'description': addGroup('description' + sqlOp, (row.filter_operator === 'contains' || row.filter_operator === 'notcontains') ? `p.notes${sqlOp}'%${strVal(t1)}%'` : `p.notes${sqlOp}'${strVal(t1)}'`, isNot); break; case 'description_char_count': addGroup('description_char_count' + sqlOp, `CHAR_LENGTH(p.notes)${sqlOp}${parseFloat(t1)||0}`); break; case 'description2': addGroup('description2' + sqlOp, (row.filter_operator === 'contains' || row.filter_operator === 'notcontains') ? `p.notes2${sqlOp}'%${strVal(t1)}%'` : `p.notes2${sqlOp}'${strVal(t1)}'`, isNot); break; case 'keyword': addGroup('keyword' + sqlOp, (row.filter_operator === 'contains' || row.filter_operator === 'notcontains') ? `p.keyword1${sqlOp}'%${strVal(t1)}%'` : `p.keyword1${sqlOp}'${strVal(t1)}'`, isNot); break; case 'notes': addGroup('notes' + sqlOp, (row.filter_operator === 'contains' || row.filter_operator === 'notcontains') ? `p.priv_notes${sqlOp}'%${strVal(t1)}%'` : `p.priv_notes${sqlOp}'${strVal(t1)}'`, isNot); break; case 'price_notes': addGroup('price_notes' + sqlOp, (row.filter_operator === 'contains' || row.filter_operator === 'notcontains') ? `p.price_notes${sqlOp}'%${strVal(t1)}%'` : `p.price_notes${sqlOp}'${strVal(t1)}'`); break; case 'itemnumber': addGroup('itemnumber_' + sqlOp, `p.itemnumber${sqlOp}'${strVal(t1)}'`, isNot); break; case 'pid': case 'pid_auto': { // filter_or=true means OR-connect this group to the previous one (PHP OROROR mechanism) const key = 'pid' + sqlOp + (filterOr ? 'OROROR' : ''); addGroup(key, `p.pid${sqlOp}${parseInt(t1) || 0}`, isNot, filterOr); break; } case 'upc': addGroup('upc' + sqlOp, `p.upc${sqlOp}'${strVal(t1)}'`, isNot); break; case 'size': addGroup('size' + sqlOp, row.filter_operator === 'begins' ? `p.size LIKE '${strVal(t1)}%'` : `p.size${sqlOp}'${strVal(t1)}'`, isNot); break; case 'country_of_origin': addGroup('country_of_origin', (row.filter_operator === 'contains' || row.filter_operator === 'notcontains') ? `p.country_of_origin${sqlOp}'%${strVal(t1)}%'` : `p.country_of_origin${sqlOp}'${strVal(t1)}'`); break; case 'date_in': addGroup('date_in' + sqlOp, dateFilt('DATE(p.datein)', sqlOp, t1, t2)); break; case 'date_in_days': case 'age': addGroup('date_in' + sqlOp, numFilt('DATEDIFF(NOW(),p.datein)', sqlOp, t1, t2)); break; case 'date_created': addGroup('date_created' + sqlOp, dateFilt('p.date_created', sqlOp, t1, t2)); break; case 'date_created_days': addGroup('date_created' + sqlOp, numFilt('DATEDIFF(NOW(),p.date_created)', sqlOp, t1, t2)); break; case 'date_modified': addGroup('date_modified' + sqlOp, dateFilt('p.stamp', sqlOp, t1, t2)); break; case 'date_modified_days': addGroup('date_modified' + sqlOp, numFilt('DATEDIFF(NOW(),p.stamp)', sqlOp, t1, t2)); break; case 'date_refill': addGroup('date_refill' + sqlOp, dateFilt('p.date_refill', sqlOp, t1, t2)); break; case 'date_refill_days': addGroup('date_refill' + sqlOp, numFilt('DATEDIFF(NOW(),p.date_refill)', sqlOp, t1, t2)); break; case 'new': addGroup('new', `DATEDIFF(NOW(),p.date_ol) <= ${parseInt(t1) || 45}`); addGroup('notnew', 'p.notnew=0'); break; case 'new_in': addGroup('new2', `p.datein BETWEEN NOW()-INTERVAL ${parseInt(t1) || 30} DAY AND NOW()`); addGroup('notnew', 'p.notnew=0'); break; case 'backinstock': addGroup('backinstock_1', `p.date_refill BETWEEN NOW()-INTERVAL ${parseInt(t1)||30} DAY AND NOW()`); addGroup('backinstock_2', 'p.date_refill > p.datein'); addGroup('backinstock_3', 'NOT (p.datein BETWEEN NOW()-INTERVAL 30 DAY AND NOW())'); break; case 'arrivals': addGroup('arrivals', `(p.date_ol BETWEEN NOW()-INTERVAL ${parseInt(t1)||30} DAY AND NOW() AND p.notnew=0)`); addGroup('arrivals', `(p.date_refill BETWEEN NOW()-INTERVAL ${parseInt(t1)||30} DAY AND NOW() AND p.date_refill > p.datein)`); break; // ── current_inventory (aliased as ci, already LEFT JOINed in PRODUCT_SELECT) ── case 'count': addGroup('count' + sqlOp, numFilt('ci.available', sqlOp, t1, t2)); break; case 'count_onhand': addGroup('count_onhand' + sqlOp, numFilt('(ci.count-ci.pending)', sqlOp, t1, t2)); break; case 'count_shelf': addGroup('count_shelf' + sqlOp, numFilt('ci.count_shelf', sqlOp, t1, t2)); break; case 'on_order': addGroup('on_order' + sqlOp, numFilt('ci.onorder', sqlOp, t1, t2)); break; case 'on_preorder': addGroup('on_preorder' + sqlOp, numFilt('ci.onpreorder', sqlOp, t1, t2)); break; case 'infinite': addGroup('infinite', `ci.infinite${sqlOp}`); break; case 'pending': addGroup('pending', numFilt('ci.pending', sqlOp, t1, t2)); break; case 'date_sold': addGroup('date_sold' + sqlOp, numFilt('DATEDIFF(NOW(),ci.lastsolddate)', sqlOp, t1, t2)); break; case 'average_cost': addGroup('avg_cost' + sqlOp, numFilt('ci.avg_cost', sqlOp, t1, t2)); break; case 'markup': addGroup('markup' + sqlOp, numFilt('(p.sellingprice/ci.avg_cost*100-100)', sqlOp, t1, t2)); break; case 'total_sold': addGroup('total_sold' + sqlOp, numFilt('ci.totalsold', sqlOp, t1, t2)); break; case 'inbaskets': addGroup('inbaskets', numFilt('ci.baskets', sqlOp, t1, t2)); break; // ── supplier_item_data (aliased as sid, already LEFT JOINed in PRODUCT_SELECT) ── case 'supplier': addGroup('supplier' + sqlOp, `sid.supplier_id${sqlOp}${parseFloat(t1)||0}`, isNot); break; case 'supplier_cost_each': addGroup('supplier_cost_each' + sqlOp, numFilt('sid.supplier_cost_each', sqlOp, t1, t2)); break; case 'notions_cost_each': addGroup('notions_cost_each' + sqlOp, numFilt('sid.notions_cost_each', sqlOp, t1, t2)); break; case 'supplier_qty_per_unit': addGroup('supplier_qty_per_unit' + sqlOp, numFilt('sid.supplier_qty_per_unit', sqlOp, t1, t2)); break; case 'notions_qty_per_unit': addGroup('notions_qty_per_unit' + sqlOp, numFilt('sid.notions_qty_per_unit', sqlOp, t1, t2)); break; case 'case_pack': addGroup('case_pack', `sid.notions_case_pack${sqlOp}${parseFloat(t1)||0}`); break; case 'notions_discontinued': addGroup('notions_discontinued' + sqlOp, `sid.notions_discontinued${sqlOp}${parseFloat(t1)||0}`, isNot); break; case 'missing_any_cost': addGroup('missing_any_cost', 'sid.supplier_cost_each=0 OR sid.notions_cost_each=0 OR sid.supplier_cost_each IS NULL OR sid.notions_cost_each IS NULL OR (SELECT COUNT(*) FROM product_inventory WHERE product_inventory.pid=p.pid)=0'); break; case 'notions_itemnumber': { // Use a separate LEFT JOIN alias so IS NULL check works correctly addJoin('sid_l', 'LEFT JOIN supplier_item_data AS sid_l ON (p.pid=sid_l.pid)'); let filt = `sid_l.notions_itemnumber${sqlOp}'${strVal(t1)}'`; if (sqlOp === '=' && t1 === '') filt += ' OR sid_l.notions_itemnumber IS NULL'; addGroup('notions_itemnumber' + sqlOp, filt, isNot); break; } case 'supplier_itemnumber': { addJoin('sid_l', 'LEFT JOIN supplier_item_data AS sid_l ON (p.pid=sid_l.pid)'); let filt = `sid_l.supplier_itemnumber${sqlOp}'${strVal(t1)}'`; if (sqlOp === '=' && t1 === '') filt += ' OR sid_l.supplier_itemnumber IS NULL'; addGroup('supplier_itemnumber' + sqlOp, filt, isNot); break; } case 'supplier_cost_each_grouped': addGroup('supplier_cost_each' + sqlOp, numFilt('sid.supplier_cost_each', sqlOp, t1, t2)); break; // ── category index: extra INNER JOINs needed ─────────────────────────── case 'type': case 'cat': ciJoin('product_ci_cat'); addGroup('cat' + sqlOp, `product_ci_cat.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break; case 'cat2': ciJoin('product_ci_cat2'); addGroup('cat2' + sqlOp, `product_ci_cat2.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break; case 'subtype': case 'subcat': ciJoin('product_ci_subcat'); addGroup('subcat' + sqlOp, `product_ci_subcat.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break; case 'subsubcat': ciJoin('product_ci_subsubcat'); addGroup('subsubcat' + sqlOp, `product_ci_subsubcat.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break; case 'section': ciJoin('product_ci_section'); addGroup('section' + sqlOp, `product_ci_section.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break; case 'theme': ciJoin('product_ci_theme'); addGroup('themes' + sqlOp, `product_ci_theme.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break; case 'subtheme': ciJoin('product_ci_subtheme'); addGroup('subtheme' + sqlOp, `product_ci_subtheme.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break; case 'digitheme': ciJoin('product_ci_digitheme'); addGroup('digithemes' + sqlOp, `product_ci_digitheme.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break; case 'all_categories': if (sqlOp === '=') { ciJoin('product_ci_allcats'); addGroup('allcategories=', `product_ci_allcats.cat_id=${parseFloat(t1)||0}`); } else { const alias = `not_ci_${parseInt(t1) || 0}`; addJoin(alias, `LEFT JOIN product_category_index AS ${alias} ON (${alias}.pid=p.pid AND ${alias}.cat_id=${parseFloat(t1)||0})`); addGroup('allcategories<>', `${alias}.cat_id IS NULL`); } break; case 'not_section': addGroup('not_section', `(SELECT 1 FROM product_category_index WHERE product_category_index.pid=p.pid AND cat_id=${parseInt(t1)||0}) IS NULL`); break; case 'all_themes': if (sqlOp === '=') { ciJoin('product_ci_allthemes'); addGroup('allthemes=', `product_ci_allthemes.cat_id=${parseFloat(t1)||0}`); } else { addJoin('product_ci_not_allthemes', `LEFT JOIN product_category_index AS product_ci_not_allthemes ON (p.pid=product_ci_not_allthemes.pid AND product_ci_not_allthemes.cat_id=${parseFloat(t1)||0})`); addGroup('allthemes<>', 'product_ci_not_allthemes.pid IS NULL'); } break; case 'has_any_category': addGroup('has_any_category', '(SELECT COUNT(*) FROM product_category_index WHERE product_category_index.pid=p.pid)>0'); break; case 'has_themes': addGroup('has_themes', `(SELECT COUNT(*) FROM product_category_index JOIN product_categories ON (product_category_index.cat_id=product_categories.cat_id AND product_categories.type IN (${CAT_THEMES})) WHERE product_category_index.pid=p.pid)>0`); break; case 'no_themes': addGroup('no_themes', `(SELECT COUNT(*) FROM product_category_index JOIN product_categories ON (product_category_index.cat_id=product_categories.cat_id AND product_categories.type IN (${CAT_THEMES})) WHERE product_category_index.pid=p.pid)=0`); break; case 'no_categories': addGroup('no_categories', `(SELECT COUNT(*) FROM product_category_index JOIN product_categories ON (product_category_index.cat_id=product_categories.cat_id AND product_categories.type IN (${CAT_CATEGORIES_NO_SECTION})) WHERE product_category_index.pid=p.pid)=0`); break; // ── color: extra JOIN product_colors ────────────────────────────────── case 'color': addJoin('product_colors', 'JOIN product_colors ON (p.pid=product_colors.pid)'); addGroup('color' + sqlOp, `product_colors.color${sqlOp}${parseInt(t1)||0}`, isNot); break; // ── product_inventory: extra JOIN (GROUP BY p.pid covers duplicates) ── case 'cost': addJoin('product_inventory', 'JOIN product_inventory ON (product_inventory.pid=p.pid)'); addGroup('cost' + sqlOp, numFilt('product_inventory.costeach', sqlOp, t1, t2)); break; case 'original_cost': addJoin('product_inventory', 'JOIN product_inventory ON (product_inventory.pid=p.pid)'); addGroup('orig_cost' + sqlOp, numFilt('product_inventory.orig_costeach', sqlOp, t1, t2)); break; case 'total_product_value': addGroup('total_product_value' + sqlOp, `(SELECT SUM(product_inventory.costeach*product_inventory.count) FROM product_inventory WHERE product_inventory.pid=p.pid)${sqlOp}${parseFloat(t1)||0}`); break; // ── shop_inventory: extra LEFT JOIN ─────────────────────────────────── // All shop_* filters share alias 'shop_inv' so they add only one JOIN case 'buyable': addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)'); addGroup('shop_inv_cond', `shop_inv.store=${ACOT_STORE} AND shop_inv.buyable=1`); break; case 'not_buyable': addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)'); addGroup('shop_inv_cond', `shop_inv.store=${ACOT_STORE} AND shop_inv.buyable=0`); break; case 'shop_available': addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)'); addGroup('shop_inv_store', `shop_inv.store=${ACOT_STORE}`); addGroup('shop_inv_avail' + sqlOp, numFilt('shop_inv.available', sqlOp, t1, t2)); break; case 'shop_available_local': { addJoin('shop_inv_local', `LEFT JOIN shop_inventory AS shop_inv_local ON (p.pid=shop_inv_local.pid AND shop_inv_local.store=${ACOT_STORE})`); const filt = sqlOp === '<' ? `(shop_inv_local.available_local${sqlOp}${parseFloat(t1)||0} OR shop_inv_local.available_local IS NULL)` : numFilt('shop_inv_local.available_local', sqlOp, t1, t2); addGroup('shop_avail_local' + sqlOp, filt); break; } case 'shop_show': addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)'); addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.show=1`); break; case 'shop_show_in': addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)'); addGroup('shop_inv_cond', `shop_inv.store IN (${strVal(t1)}) AND shop_inv.show=1`); break; case 'shop_buyable': addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)'); addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.buyable=1`); break; case 'shop_preorder': addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)'); addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.\`all\`=2`); break; case 'shop_not_preorder': addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)'); addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.\`all\`!=2`); break; case 'shop_inventory_source_acot': addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)'); addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.inventory_source=${SRC_ACOT}`); break; case 'shop_inventory_source_acot_or_pre': addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)'); addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.inventory_source IN (${SRC_ACOT},${SRC_PREORDER})`); break; case 'shop_inventory_source_notions': addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)'); addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.inventory_source=${SRC_NOTIONS}`); break; case 'shop_inventory_source_not_notions': addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)'); addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.inventory_source!=${SRC_NOTIONS}`); break; case 'preorder_item': addJoin('shop_inv_pre', `LEFT JOIN shop_inventory AS shop_inv_pre ON (p.pid=shop_inv_pre.pid AND shop_inv_pre.store=${ACOT_STORE})`); addGroup('preorder_item', `shop_inv_pre.inventory_source=${SRC_PREORDER}`); break; case 'not_preorder_item': addJoin('shop_inv_pre', `LEFT JOIN shop_inventory AS shop_inv_pre ON (p.pid=shop_inv_pre.pid AND shop_inv_pre.store=${ACOT_STORE})`); addGroup('preorder_item', `shop_inv_pre.inventory_source!=${SRC_PREORDER} OR shop_inv_pre.inventory_source IS NULL`); break; // ── product_notions: extra JOIN ──────────────────────────────────────── case 'notions_use_inventory': addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)'); addGroup('notions_use_inv', `product_notions.use_inventory${sqlOp}`); break; case 'notions_inventory': addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)'); addGroup('notions_inv', `product_notions.inventory${sqlOp}${parseInt(t1)||0}`); break; case 'notions_sell_qty': addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)'); addGroup('notions_sell_qty', `product_notions.sell_qty${sqlOp}${parseInt(t1)||0}`); break; case 'notions_csc': addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)'); addGroup('notions_csc', (row.filter_operator === 'contains' || row.filter_operator === 'notcontains') ? `product_notions.csc${sqlOp}'%${strVal(t1)}%'` : `product_notions.csc${sqlOp}'${strVal(t1)}'`); break; case 'notions_top_sellers': addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)'); addGroup('notions_top_sellers', 'product_notions.top_seller>0'); break; case 'notions_cost_higher_than_selling_price': addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)'); addGroup('notions_cost_vs_sell', '(product_notions.sell_cost*product_notions.sell_qty) > p.sellingprice'); break; case 'notions_order_qty_conversion_our': addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)'); addGroup('notion_oqc_our' + sqlOp, numFilt('product_notions.order_qty_conversion_our', sqlOp, t1, t2)); break; case 'notions_order_qty_conversion_notions': addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)'); addGroup('notion_oqc_notions' + sqlOp, numFilt('product_notions.order_qty_conversion_notions', sqlOp, t1, t2)); break; // ── product_backorders: extra LEFT JOIN ──────────────────────────────── case 'backorder_qty_any': addJoin('product_backorders', 'LEFT JOIN product_backorders ON (product_backorders.pid=p.pid)'); break; // join only, no WHERE condition case 'backorder_qty_notions': addJoin('product_backorders', 'LEFT JOIN product_backorders ON (product_backorders.pid=p.pid)'); addGroup('backorder_qty_notions', `product_backorders.qty${sqlOp}${parseInt(t1)||0} AND product_backorders.supplier=92`); break; // ── product_related: extra JOIN ──────────────────────────────────────── case 'related': addJoin('product_related', 'JOIN product_related ON (product_related.to_pid=p.pid)'); addGroup('related_pid', `product_related.pid${sqlOp}${parseInt(t1)||0}`); if (t2) addGroup('related_type', `product_related.type=${parseInt(t2)||0}`); break; case 'no_relations': addJoin('product_related_nr', 'LEFT JOIN product_related AS product_related_nr ON (product_related_nr.pid=p.pid)'); addGroup('no_relations', 'product_related_nr.to_pid IS NULL'); break; // ── receivings / PO: extra INNER JOINs ──────────────────────────────── case 'receiving_id': addJoin('receivings_products', 'JOIN receivings_products ON (p.pid=receivings_products.pid)'); addGroup('receiving_id' + sqlOp, `receivings_products.receiving_id${sqlOp}'${strVal(t1)}'`, isNot); break; case 'po_id': addJoin('po_products', 'JOIN po_products ON (p.pid=po_products.pid)'); addGroup('po_id' + sqlOp, `po_products.po_id${sqlOp}'${strVal(t1)}'`, isNot); break; // ── subquery filters ─────────────────────────────────────────────────── case 'missing_images': addGroup('missing_images', (row.filter_operator === 'true' || row.filter_operator === 'true1') ? '(SELECT COUNT(*) FROM product_images WHERE product_images.pid=p.pid)=0' : '(SELECT COUNT(*) FROM product_images WHERE product_images.pid=p.pid)>0'); break; case 'current_price': addGroup('current_price' + sqlOp, `(SELECT MIN(price_each) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1)${sqlOp}${parseFloat(t1)||0}`); break; case 'current_price_sale_percent': { const v1 = (parseFloat(t1)||0) > 1 ? (parseFloat(t1)/100) : (parseFloat(t1)||0); const pe = '(SELECT MIN(price_each) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1)'; addGroup('current_price_sale_pct' + sqlOp, `(1 - ${pe} / p.sellingprice)${sqlOp}${v1}`); break; } case 'one_current_price': addGroup('one_current_price', '(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1)=1'); break; case 'multiple_current_prices': addGroup('multiple_current_prices', '(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1)>1'); break; case 'current_price_is_missing': addGroup('current_price_is_missing', '(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1)=0'); break; case 'current_price_not_one_buyable': addGroup('current_price_not_one_buyable', '(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND qty_buy=1)=0 AND (SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1)>0'); break; case 'current_price_min_buy': { const cond = t2 ? `(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND is_min_qty_buy=1 AND qty_buy${sqlOp}${parseFloat(t1)||0} AND ${parseFloat(t2)||0})>0` : `(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND is_min_qty_buy=1 AND qty_buy${sqlOp}${parseFloat(t1)||0})>0`; addGroup('current_price_min_buy' + sqlOp, cond); break; } case 'current_price_each_buy': { const cond = t2 ? `(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND is_min_qty_buy=0 AND qty_buy${sqlOp}${parseFloat(t1)||0} AND ${parseFloat(t2)||0})>0` : `(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND is_min_qty_buy=0 AND qty_buy${sqlOp}${parseFloat(t1)||0})>0`; addGroup('current_price_each_buy' + sqlOp, cond); break; } case 'current_price_max_qty': { const cond = t2 ? `(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND qty_limit${sqlOp}${parseFloat(t1)||0} AND ${parseFloat(t2)||0})>0` : `(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND qty_limit${sqlOp}${parseFloat(t1)||0})>0`; addGroup('current_price_max_qty' + sqlOp, cond); break; } case 'current_price_is_checkout_offer': addGroup('current_price_checkout', `(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND checkout_offer${sqlOp})>0`); break; case 'current_price_has_extra': addJoin('cp_extras', 'JOIN (SELECT pid,count(*) ct FROM product_current_prices WHERE active=1 GROUP BY pid,qty_buy HAVING ct>1) AS cp_extras ON (cp_extras.pid=p.pid)'); addGroup('cp_extra', 'cp_extras.ct>1'); break; case 'has_video': addGroup('has_video', '(SELECT COUNT(*) FROM product_media WHERE product_media.pid=p.pid)>0'); break; case 'notions_created': addGroup('notions_created', sqlOp !== '=0' ? '(SELECT COUNT(*) FROM product_notions_created WHERE product_notions_created.pid=p.pid)>0' : '(SELECT COUNT(*) FROM product_notions_created WHERE product_notions_created.pid=p.pid)=0'); break; case 'notifications': addJoin('pnc', 'JOIN (SELECT pid,COUNT(*) AS notifications_count FROM product_notify GROUP BY pid) pnc ON (pnc.pid=p.pid)'); addGroup('notifications', numFilt('pnc.notifications_count', sqlOp, t1, t2)); break; case 'daily_deal': addGroup('daily_deal', '(SELECT deal_id FROM product_daily_deals WHERE deal_date=CURDATE() AND product_daily_deals.pid=p.pid)>0'); break; // ── amazon_price (store id 5, legacy) ───────────────────────────────── case 'amazon_price': { const AMAZON_STORE = 5; addJoin('product_prices', 'LEFT JOIN product_prices ON (p.pid=product_prices.pid)'); let filt = `product_prices.store=${AMAZON_STORE} AND product_prices.price${sqlOp}${parseFloat(t1)||0}`; if (t2) filt += ` AND ${parseFloat(t2)||0}`; if ((sqlOp === '<=' || sqlOp === '=') && t1 === '0') filt += ' OR product_prices.price IS NULL'; addGroup('amazon_price' + sqlOp, filt); break; } // ── basket (cid must be in filter_text2; skip if not set) ───────────── case 'basket': if (!t2) { unsupported.push('basket(no-cid)'); break; } addJoin('mybasket', 'JOIN mybasket ON (p.pid=mybasket.item)'); addGroup('basket_cid', `mybasket.cid=${parseInt(t2)||0}`); addGroup('basket_sid', `mybasket.sid=0`); // ACOT store addGroup('basket_bid', t1 ? `mybasket.bid=${parseInt(t1)||0}` : 'mybasket.bid=0'); if (t1 === '2') addGroup('basket_qty', 'mybasket.qty>0'); break; // ── hot: recent sales aggregation ───────────────────────────────────── case 'hot': { const days = parseInt(t1) || 30; const alias = `hot_${days}`; addJoin(alias, `JOIN (SELECT prod_pid, COUNT(*) hot_ct, SUM(order_items.qty_ordered) hot_sm FROM order_items JOIN _order ON (_order.order_id=order_items.order_id AND _order.order_status>60 AND _order.date_placed BETWEEN NOW()-INTERVAL ${days} DAY AND NOW()) GROUP BY order_items.prod_pid) ${alias} ON (p.pid=${alias}.prod_pid)`); break; // join alone filters to products with recent sales; no extra WHERE needed } // Intentionally skipped (require external services or missing runtime context): // search/search_any (MeiliSearch API), basket without cid (per-session user), // groupby (display aggregation, not a filter) default: unsupported.push(row.filter_type); break; } } // Assemble WHERE: groups are AND-connected by default. // A group with ororor=true is OR-connected to the previous group instead (PHP filter_or mechanism). let whereClause = ''; let i = 0; for (const [, group] of filterGroups) { const wrapped = group.conditions.map(c => `(${c})`); const innerJoiner = group.isNot ? ' AND ' : ' OR '; const part = `(${wrapped.join(innerJoiner)})`; if (i > 0) { whereClause += group.ororor ? ` OR ${part}` : ` AND ${part}`; } else { whereClause += part; } i++; } const joinClauses = [...joinTables.values()].join('\n '); return { whereClause, joinClauses, unsupported }; } // Load products matching a saved product_query by query_id // Filter types whose v1/v2 values are cat_ids in product_categories const CAT_ID_FILTER_TYPES = new Set([ 'company', 'line', 'subline', 'artist', 'category', 'theme', 'size_cat', 'dimension', 'yarn_weight', 'material', ]); async function resolveFilterLabels(connection, filters) { const catIds = new Set(); const supplierIds = new Set(); const taxCodeIds = new Set(); for (const f of filters) { const vals = [f.v1, f.v2].filter(v => v && !isNaN(Number(v))); if (CAT_ID_FILTER_TYPES.has(f.type)) vals.forEach(v => catIds.add(Number(v))); else if (f.type === 'investor') vals.forEach(v => supplierIds.add(Number(v))); else if (f.type === 'tax_code') vals.forEach(v => taxCodeIds.add(Number(v))); } const catLookup = {}; const supplierLookup = {}; const taxCodeLookup = {}; if (catIds.size) { const ids = [...catIds]; const [rows] = await connection.query( `SELECT cat_id, name FROM product_categories WHERE cat_id IN (${ids.map(() => '?').join(',')})`, ids ); for (const r of rows) catLookup[r.cat_id] = r.name; } if (supplierIds.size) { const ids = [...supplierIds]; const [rows] = await connection.query( `SELECT supplierid, companyname FROM suppliers WHERE supplierid IN (${ids.map(() => '?').join(',')})`, ids ); for (const r of rows) supplierLookup[r.supplierid] = r.companyname; } if (taxCodeIds.size) { const ids = [...taxCodeIds]; const [rows] = await connection.query( `SELECT tax_code_id, name FROM product_tax_codes WHERE tax_code_id IN (${ids.map(() => '?').join(',')})`, ids ); for (const r of rows) taxCodeLookup[r.tax_code_id] = r.name; } function labelFor(type, val) { if (!val || isNaN(Number(val))) return null; const id = Number(val); if (CAT_ID_FILTER_TYPES.has(type)) return catLookup[id] ?? null; if (type === 'investor') return supplierLookup[id] ?? null; if (type === 'tax_code') return taxCodeLookup[id] ?? null; return null; } return filters.map(f => ({ ...f, v1Label: labelFor(f.type, f.v1), v2Label: labelFor(f.type, f.v2), })); } router.get('/query-products', async (req, res) => { const { query_id } = req.query; if (!query_id || isNaN(parseInt(query_id))) { return res.status(400).json({ error: 'Valid query_id is required' }); } const qid = parseInt(query_id); try { const { connection } = await getDbConnection(); // Verify query exists const [queryRows] = await connection.query( 'SELECT id, name FROM product_query WHERE id = ?', [qid] ); if (!queryRows.length) { return res.status(404).json({ error: `Query ${qid} not found` }); } // Load all filters for this query const [filterRows] = await connection.query( 'SELECT * FROM product_query_filter WHERE query_id = ? ORDER BY id', [qid] ); if (!filterRows.length) { return res.json({ results: [], filters: [] }); } const { whereClause, joinClauses, unsupported } = buildQueryFilterSql(filterRows); const uniqueUnsupported = [...new Set(unsupported)]; if (uniqueUnsupported.length) { console.warn(`query-products: unsupported filter types for query ${qid}:`, uniqueUnsupported); } res.setHeader('X-Query-Name', queryRows[0].name || ''); function tryDecode(v) { if (!v) return null; try { return decodeURIComponent(String(v)); } catch { return String(v); } } const rawFilters = filterRows .filter(r => !uniqueUnsupported.includes(r.filter_type)) .map(r => ({ type: r.filter_type, op: r.filter_operator, v1: tryDecode(r.filter_text1), v2: tryDecode(r.filter_text2), })); const filters = await resolveFilterLabels(connection, rawFilters); // If all filters were unsupported the WHERE clause is empty — return nothing // rather than dumping the entire products table. if (!whereClause) { return res.json({ results: [], filters, unsupported: uniqueUnsupported }); } const sql = `${PRODUCT_SELECT} ${joinClauses} WHERE ${whereClause} GROUP BY p.pid ORDER BY p.description LIMIT 2000`; const [results] = await connection.query(sql); res.json({ results, filters, unsupported: uniqueUnsupported }); } catch (error) { console.error('Error loading query products:', error); res.status(500).json({ error: 'Failed to load query products', details: error.message }); } }); export default router;