Files
inventory/inventory-server/src/routes/import.js
T

2967 lines
119 KiB
JavaScript

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=<upc>, 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<key, { conditions: string[], isNot: boolean, ororor: boolean }>
// 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;