2967 lines
119 KiB
JavaScript
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;
|