Add audit log for product import, add tiff image support, add new/preorder filters on product editor, fix sorting in product editor
This commit is contained in:
53
inventory-server/migrations/003_create_import_audit_log.sql
Normal file
53
inventory-server/migrations/003_create_import_audit_log.sql
Normal file
@@ -0,0 +1,53 @@
|
||||
-- Migration: Create import_audit_log table
|
||||
-- Permanent audit trail of all product import submissions sent to the API
|
||||
-- Run this against your PostgreSQL database
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_audit_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
||||
-- Who initiated the import
|
||||
user_id INTEGER NOT NULL,
|
||||
username VARCHAR(255),
|
||||
|
||||
-- What was submitted
|
||||
product_count INTEGER NOT NULL,
|
||||
request_payload JSONB NOT NULL, -- The exact JSON array of products sent to the API
|
||||
environment VARCHAR(10) NOT NULL, -- 'dev' or 'prod'
|
||||
target_endpoint VARCHAR(255), -- The API URL that was called
|
||||
use_test_data_source BOOLEAN DEFAULT FALSE,
|
||||
|
||||
-- What came back
|
||||
success BOOLEAN NOT NULL,
|
||||
response_payload JSONB, -- Full API response
|
||||
error_message TEXT, -- Extracted error message on failure
|
||||
created_count INTEGER DEFAULT 0, -- Number of products successfully created
|
||||
errored_count INTEGER DEFAULT 0, -- Number of products that errored
|
||||
|
||||
-- Metadata
|
||||
session_id INTEGER, -- Optional link to the import_session used (if any)
|
||||
duration_ms INTEGER, -- How long the API call took
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Index for looking up logs by user
|
||||
CREATE INDEX IF NOT EXISTS idx_import_audit_log_user_id
|
||||
ON import_audit_log (user_id);
|
||||
|
||||
-- Index for filtering by success/failure
|
||||
CREATE INDEX IF NOT EXISTS idx_import_audit_log_success
|
||||
ON import_audit_log (success);
|
||||
|
||||
-- Index for time-based queries
|
||||
CREATE INDEX IF NOT EXISTS idx_import_audit_log_created_at
|
||||
ON import_audit_log (created_at DESC);
|
||||
|
||||
-- Composite index for user + time queries
|
||||
CREATE INDEX IF NOT EXISTS idx_import_audit_log_user_created
|
||||
ON import_audit_log (user_id, created_at DESC);
|
||||
|
||||
COMMENT ON TABLE import_audit_log IS 'Permanent audit log of all product import API submissions';
|
||||
COMMENT ON COLUMN import_audit_log.request_payload IS 'Exact JSON products array sent to the external API';
|
||||
COMMENT ON COLUMN import_audit_log.response_payload IS 'Full response received from the external API';
|
||||
COMMENT ON COLUMN import_audit_log.environment IS 'dev or prod - which API endpoint was targeted';
|
||||
COMMENT ON COLUMN import_audit_log.session_id IS 'Optional reference to import_sessions.id if session was active';
|
||||
COMMENT ON COLUMN import_audit_log.duration_ms IS 'Round-trip time of the API call in milliseconds';
|
||||
193
inventory-server/src/routes/import-audit-log.js
Normal file
193
inventory-server/src/routes/import-audit-log.js
Normal file
@@ -0,0 +1,193 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Create a new audit log entry
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
user_id,
|
||||
username,
|
||||
product_count,
|
||||
request_payload,
|
||||
environment,
|
||||
target_endpoint,
|
||||
use_test_data_source,
|
||||
success,
|
||||
response_payload,
|
||||
error_message,
|
||||
created_count,
|
||||
errored_count,
|
||||
session_id,
|
||||
duration_ms,
|
||||
} = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!user_id) {
|
||||
return res.status(400).json({ error: 'user_id is required' });
|
||||
}
|
||||
if (!request_payload) {
|
||||
return res.status(400).json({ error: 'request_payload is required' });
|
||||
}
|
||||
if (typeof success !== 'boolean') {
|
||||
return res.status(400).json({ error: 'success (boolean) is required' });
|
||||
}
|
||||
|
||||
const pool = req.app.locals.pool;
|
||||
if (!pool) {
|
||||
throw new Error('Database pool not initialized');
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
INSERT INTO import_audit_log (
|
||||
user_id,
|
||||
username,
|
||||
product_count,
|
||||
request_payload,
|
||||
environment,
|
||||
target_endpoint,
|
||||
use_test_data_source,
|
||||
success,
|
||||
response_payload,
|
||||
error_message,
|
||||
created_count,
|
||||
errored_count,
|
||||
session_id,
|
||||
duration_ms
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING id, created_at
|
||||
`, [
|
||||
user_id,
|
||||
username || null,
|
||||
product_count || 0,
|
||||
JSON.stringify(request_payload),
|
||||
environment || 'prod',
|
||||
target_endpoint || null,
|
||||
use_test_data_source || false,
|
||||
success,
|
||||
response_payload ? JSON.stringify(response_payload) : null,
|
||||
error_message || null,
|
||||
created_count || 0,
|
||||
errored_count || 0,
|
||||
session_id || null,
|
||||
duration_ms || null,
|
||||
]);
|
||||
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error creating import audit log:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to create import audit log',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// List audit log entries (with pagination)
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { user_id, limit = 50, offset = 0, success: successFilter } = req.query;
|
||||
|
||||
const pool = req.app.locals.pool;
|
||||
if (!pool) {
|
||||
throw new Error('Database pool not initialized');
|
||||
}
|
||||
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (user_id) {
|
||||
conditions.push(`user_id = $${paramIndex++}`);
|
||||
params.push(user_id);
|
||||
}
|
||||
|
||||
if (successFilter !== undefined) {
|
||||
conditions.push(`success = $${paramIndex++}`);
|
||||
params.push(successFilter === 'true');
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0
|
||||
? `WHERE ${conditions.join(' AND ')}`
|
||||
: '';
|
||||
|
||||
// Get total count
|
||||
const countResult = await pool.query(
|
||||
`SELECT COUNT(*) FROM import_audit_log ${whereClause}`,
|
||||
params
|
||||
);
|
||||
|
||||
// Get paginated results (exclude large payload columns in list view)
|
||||
const dataParams = [...params, parseInt(limit, 10), parseInt(offset, 10)];
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
username,
|
||||
product_count,
|
||||
environment,
|
||||
target_endpoint,
|
||||
use_test_data_source,
|
||||
success,
|
||||
error_message,
|
||||
created_count,
|
||||
errored_count,
|
||||
session_id,
|
||||
duration_ms,
|
||||
created_at
|
||||
FROM import_audit_log
|
||||
${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex++}
|
||||
`, dataParams);
|
||||
|
||||
res.json({
|
||||
total: parseInt(countResult.rows[0].count, 10),
|
||||
entries: result.rows,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching import audit log:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch import audit log',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get a single audit log entry (with full payloads)
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const pool = req.app.locals.pool;
|
||||
if (!pool) {
|
||||
throw new Error('Database pool not initialized');
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM import_audit_log WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Audit log entry not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error fetching import audit log entry:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch audit log entry',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Error handling middleware
|
||||
router.use((err, req, res, next) => {
|
||||
console.error('Import audit log route error:', err);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
details: err.message
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -194,7 +194,7 @@ const processUploadedImage = async (filePath, mimetype) => {
|
||||
legacyWarnings.push(message);
|
||||
}
|
||||
|
||||
const format = (baseMetadata.format || '').toLowerCase();
|
||||
let format = (baseMetadata.format || '').toLowerCase();
|
||||
if (format === 'gif') {
|
||||
if (metadata.size > MAX_IMAGE_SIZE_BYTES) {
|
||||
const message = `GIF optimization is limited; resulting size is ${bytesToMegabytes(metadata.size)}MB (target 5MB).`;
|
||||
@@ -211,6 +211,16 @@ const processUploadedImage = async (filePath, mimetype) => {
|
||||
return { notices, warnings: legacyWarnings, metadata, finalSize: metadata.size };
|
||||
}
|
||||
|
||||
// TIFF: convert to JPEG (don't store TIFF files)
|
||||
let convertedFromTiff = false;
|
||||
if (format === 'tiff') {
|
||||
convertedFromTiff = true;
|
||||
format = 'jpeg';
|
||||
const message = 'Converted from TIFF to JPEG.';
|
||||
notices.push({ message, level: 'info', code: 'converted_from_tiff', source: 'server' });
|
||||
legacyWarnings.push(message);
|
||||
}
|
||||
|
||||
const supportsQuality = ['jpeg', 'jpg', 'webp'].includes(format);
|
||||
let targetQuality = supportsQuality ? 90 : undefined;
|
||||
let finalQuality = undefined;
|
||||
@@ -343,8 +353,8 @@ const processUploadedImage = async (filePath, mimetype) => {
|
||||
});
|
||||
legacyWarnings.push(message);
|
||||
}
|
||||
} else if (shouldConvertToRgb) {
|
||||
const { data, info } = await encode({ width: currentWidth, height: currentHeight });
|
||||
} else if (shouldConvertToRgb || convertedFromTiff) {
|
||||
const { data, info } = await encode({ width: currentWidth, height: currentHeight, quality: targetQuality });
|
||||
mutated = true;
|
||||
finalBuffer = data;
|
||||
finalInfo = info;
|
||||
@@ -363,6 +373,15 @@ const processUploadedImage = async (filePath, mimetype) => {
|
||||
metadata.optimizedSize = metadata.size;
|
||||
}
|
||||
|
||||
// Rename TIFF files to .jpg after conversion
|
||||
let newFilePath = null;
|
||||
if (convertedFromTiff) {
|
||||
newFilePath = filePath.replace(/\.tiff?$/i, '.jpg');
|
||||
if (newFilePath !== filePath) {
|
||||
await fsp.rename(filePath, newFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
metadata.convertedToRgb = shouldConvertToRgb && mutated;
|
||||
metadata.resized = resized;
|
||||
if (finalQuality) {
|
||||
@@ -396,6 +415,7 @@ const processUploadedImage = async (filePath, mimetype) => {
|
||||
warnings: legacyWarnings,
|
||||
metadata,
|
||||
finalSize: finalBuffer.length,
|
||||
newFilePath,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -419,6 +439,7 @@ const storage = multer.diskStorage({
|
||||
case 'image/png': fileExt = '.png'; break;
|
||||
case 'image/gif': fileExt = '.gif'; break;
|
||||
case 'image/webp': fileExt = '.webp'; break;
|
||||
case 'image/tiff': fileExt = '.tif'; break;
|
||||
default: fileExt = '.jpg'; // Default to jpg
|
||||
}
|
||||
}
|
||||
@@ -436,7 +457,7 @@ const upload = multer({
|
||||
},
|
||||
fileFilter: function (req, file, cb) {
|
||||
// Accept only image files
|
||||
const filetypes = /jpeg|jpg|png|gif|webp/;
|
||||
const filetypes = /jpeg|jpg|png|gif|webp|tiff?/;
|
||||
const mimetype = filetypes.test(file.mimetype);
|
||||
const extname = filetypes.test(path.extname(file.originalname).toLowerCase());
|
||||
|
||||
@@ -633,13 +654,19 @@ router.post('/upload-image', upload.single('image'), async (req, res) => {
|
||||
const processingResult = await processUploadedImage(filePath, req.file.mimetype);
|
||||
req.file.size = processingResult.finalSize;
|
||||
|
||||
// If TIFF was converted to JPG, update filename to match the renamed file
|
||||
const effectivePath = processingResult.newFilePath || filePath;
|
||||
if (processingResult.newFilePath) {
|
||||
req.file.filename = path.basename(processingResult.newFilePath);
|
||||
}
|
||||
|
||||
// Create URL for the uploaded file - using an absolute URL with domain
|
||||
// This will generate a URL like: https://acot.site/uploads/products/filename.jpg
|
||||
const baseUrl = 'https://tools.acherryontop.com';
|
||||
const imageUrl = `${baseUrl}/uploads/products/${req.file.filename}`;
|
||||
|
||||
// Schedule this image for deletion in 24 hours
|
||||
scheduleImageDeletion(req.file.filename, filePath);
|
||||
scheduleImageDeletion(req.file.filename, effectivePath);
|
||||
|
||||
// Return success response with image URL
|
||||
res.status(200).json({
|
||||
@@ -1308,8 +1335,11 @@ const PRODUCT_SELECT = `
|
||||
pls.date_sold AS date_last_sold,
|
||||
IF(p.tax_code IS NULL, '', CAST(p.tax_code AS CHAR)) AS tax_code,
|
||||
CAST(p.size_cat AS CHAR) AS size_cat,
|
||||
CAST(p.shipping_restrictions AS CHAR) AS shipping_restrictions
|
||||
CAST(p.shipping_restrictions AS CHAR) AS shipping_restrictions,
|
||||
IF(DATEDIFF(NOW(), p.date_ol) <= 45 AND p.notnew = 0 AND (si_feed.all IS NULL OR si_feed.all != 2), 1, 0) AS is_new,
|
||||
IF(si_feed.all = 2, 1, 0) AS is_preorder
|
||||
FROM products p
|
||||
LEFT JOIN shop_inventory si_feed ON p.pid = si_feed.pid AND si_feed.store = 0
|
||||
LEFT JOIN product_current_prices pcp ON p.pid = pcp.pid AND pcp.active = 1
|
||||
LEFT JOIN supplier_item_data sid ON p.pid = sid.pid
|
||||
LEFT JOIN suppliers s ON sid.supplier_id = s.supplierid
|
||||
@@ -1334,7 +1364,7 @@ router.get('/line-products', async (req, res) => {
|
||||
where += ' AND p.subline = ?';
|
||||
params.push(Number(subline));
|
||||
}
|
||||
const query = `${PRODUCT_SELECT} ${where} GROUP BY p.pid ORDER BY p.description`;
|
||||
const query = `${PRODUCT_SELECT} ${where} GROUP BY p.pid ORDER BY IF(p.date_ol != '0000-00-00 00:00:00', p.date_ol, p.date_created) DESC, p.description`;
|
||||
const [results] = await connection.query(query, params);
|
||||
res.json(results);
|
||||
} catch (error) {
|
||||
@@ -1501,7 +1531,7 @@ router.get('/path-products', async (req, res) => {
|
||||
return res.status(400).json({ error: 'No valid filters found in path' });
|
||||
}
|
||||
|
||||
const query = `${PRODUCT_SELECT} WHERE ${whereParts.join(' AND ')} GROUP BY p.pid ORDER BY p.description`;
|
||||
const query = `${PRODUCT_SELECT} WHERE ${whereParts.join(' AND ')} GROUP BY p.pid ORDER BY IF(p.date_ol != '0000-00-00 00:00:00', p.date_ol, p.date_created) DESC, p.description`;
|
||||
const [results] = await connection.query(query, params);
|
||||
res.json(results);
|
||||
} catch (error) {
|
||||
@@ -1552,6 +1582,60 @@ router.get('/product-images/:pid', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Batch fetch product images for multiple PIDs
|
||||
router.get('/product-images-batch', async (req, res) => {
|
||||
const { pids } = req.query;
|
||||
if (!pids) {
|
||||
return res.status(400).json({ error: 'pids query parameter is required' });
|
||||
}
|
||||
const pidList = String(pids).split(',').map(Number).filter(n => n > 0);
|
||||
if (pidList.length === 0) {
|
||||
return res.json({});
|
||||
}
|
||||
|
||||
try {
|
||||
const { connection } = await getDbConnection();
|
||||
const placeholders = pidList.map(() => '?').join(',');
|
||||
const [rows] = await connection.query(
|
||||
`SELECT pid, iid, type, width, height, \`order\`, hidden FROM product_images WHERE pid IN (${placeholders}) ORDER BY \`order\` DESC, type`,
|
||||
pidList
|
||||
);
|
||||
|
||||
const typeMap = { 1: 'o', 2: 'l', 3: 't', 4: '100x100', 5: '175x175', 6: '300x300', 7: '600x600', 8: '500x500', 9: '150x150' };
|
||||
const result = {};
|
||||
for (const pid of pidList) {
|
||||
result[pid] = {};
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const typeName = typeMap[row.type];
|
||||
if (!typeName) continue;
|
||||
const pid = row.pid;
|
||||
if (!result[pid]) result[pid] = {};
|
||||
if (!result[pid][row.iid]) {
|
||||
result[pid][row.iid] = { iid: row.iid, order: row.order, hidden: !!row.hidden, sizes: {} };
|
||||
}
|
||||
const padded = String(pid).padStart(10, '0');
|
||||
const pathPrefix = `${padded.substring(0, 4)}/${padded.substring(4, 7)}/`;
|
||||
result[pid][row.iid].sizes[typeName] = {
|
||||
width: row.width,
|
||||
height: row.height,
|
||||
url: `https://sbing.com/i/products/${pathPrefix}${pid}-${typeName}-${row.iid}.jpg`,
|
||||
};
|
||||
}
|
||||
|
||||
// Convert each pid's iid map to sorted array
|
||||
const output = {};
|
||||
for (const pid of pidList) {
|
||||
output[pid] = Object.values(result[pid] || {}).sort((a, b) => b.order - a.order);
|
||||
}
|
||||
res.json(output);
|
||||
} catch (error) {
|
||||
console.error('Error fetching batch product images:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch product images' });
|
||||
}
|
||||
});
|
||||
|
||||
const UPC_SUPPLIER_PREFIX_LEADING_DIGIT = '4';
|
||||
const UPC_MAX_SEQUENCE = 99999;
|
||||
const UPC_RESERVATION_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
@@ -24,6 +24,7 @@ const vendorsAggregateRouter = require('./routes/vendorsAggregate');
|
||||
const brandsAggregateRouter = require('./routes/brandsAggregate');
|
||||
const htsLookupRouter = require('./routes/hts-lookup');
|
||||
const importSessionsRouter = require('./routes/import-sessions');
|
||||
const importAuditLogRouter = require('./routes/import-audit-log');
|
||||
const newsletterRouter = require('./routes/newsletter');
|
||||
|
||||
// Get the absolute path to the .env file
|
||||
@@ -133,6 +134,7 @@ async function startServer() {
|
||||
app.use('/api/reusable-images', reusableImagesRouter);
|
||||
app.use('/api/hts-lookup', htsLookupRouter);
|
||||
app.use('/api/import-sessions', importSessionsRouter);
|
||||
app.use('/api/import-audit-log', importAuditLogRouter);
|
||||
app.use('/api/newsletter', newsletterRouter);
|
||||
|
||||
// Basic health check route
|
||||
|
||||
@@ -192,11 +192,13 @@ export function ProductEditForm({
|
||||
product,
|
||||
fieldOptions,
|
||||
layoutMode,
|
||||
initialImages,
|
||||
onClose,
|
||||
}: {
|
||||
product: SearchProduct;
|
||||
fieldOptions: FieldOptions;
|
||||
layoutMode: LayoutMode;
|
||||
initialImages?: ProductImage[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [lineOptions, setLineOptions] = useState<LineOption[]>([]);
|
||||
@@ -260,10 +262,14 @@ export function ProductEditForm({
|
||||
originalValuesRef.current = { ...formValues };
|
||||
reset(formValues);
|
||||
|
||||
// Fetch images and categories with abort support
|
||||
// Fetch categories (and images if not pre-fetched) with abort support
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
|
||||
if (initialImages) {
|
||||
setProductImages(initialImages);
|
||||
originalImagesRef.current = initialImages;
|
||||
} else {
|
||||
setIsLoadingImages(true);
|
||||
axios
|
||||
.get(`/api/import/product-images/${product.pid}`, { signal })
|
||||
@@ -273,6 +279,7 @@ export function ProductEditForm({
|
||||
})
|
||||
.catch((e) => { if (!axios.isCancel(e)) toast.error("Failed to load product images"); })
|
||||
.finally(() => setIsLoadingImages(false));
|
||||
}
|
||||
|
||||
axios
|
||||
.get(`/api/import/product-categories/${product.pid}`, { signal })
|
||||
@@ -299,6 +306,14 @@ export function ProductEditForm({
|
||||
return () => controller.abort();
|
||||
}, [product, reset]);
|
||||
|
||||
// Apply batch-fetched images when they arrive after mount
|
||||
useEffect(() => {
|
||||
if (initialImages && productImages.length === 0 && !isLoadingImages) {
|
||||
setProductImages(initialImages);
|
||||
originalImagesRef.current = initialImages;
|
||||
}
|
||||
}, [initialImages]);
|
||||
|
||||
// Load lines when company changes (cached across forms)
|
||||
useEffect(() => {
|
||||
if (!watchCompany) {
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface SearchProduct {
|
||||
tax_code?: string;
|
||||
size_cat?: string;
|
||||
shipping_restrictions?: string;
|
||||
is_new?: number;
|
||||
is_preorder?: number;
|
||||
}
|
||||
|
||||
export interface FieldOption {
|
||||
|
||||
@@ -20,7 +20,7 @@ export const GenericDropzone = ({
|
||||
}: GenericDropzoneProps) => {
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
accept: {
|
||||
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp']
|
||||
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.tif', '.tiff']
|
||||
},
|
||||
onDrop,
|
||||
multiple: true
|
||||
|
||||
@@ -10,7 +10,7 @@ interface ImageDropzoneProps {
|
||||
export const ImageDropzone = ({ onDrop }: ImageDropzoneProps) => {
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
accept: {
|
||||
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp']
|
||||
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.tif', '.tiff']
|
||||
},
|
||||
onDrop: (acceptedFiles) => {
|
||||
onDrop(acceptedFiles);
|
||||
|
||||
@@ -776,7 +776,7 @@ export default function BulkEdit() {
|
||||
{isLoadingProducts && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading new products...
|
||||
Loading products...
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
@@ -792,7 +792,7 @@ export default function BulkEdit() {
|
||||
{isLoadingProducts && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading pre-order products...
|
||||
Loading products...
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
@@ -801,7 +801,7 @@ export default function BulkEdit() {
|
||||
{isLoadingProducts && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading hidden recently-created products...
|
||||
Loading products...
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Separator } from "@/components/ui/separator";
|
||||
import type { Data, DataValue, FieldType, Result, SubmitOptions } from "@/components/product-import/types";
|
||||
import { BASE_IMPORT_FIELDS, type ImportFieldKey } from "@/components/product-import/config";
|
||||
import { submitNewProducts, type SubmitNewProductsResponse } from "@/services/apiv2";
|
||||
import { createImportAuditLog } from "@/services/importAuditLogApi";
|
||||
import { AuthContext } from "@/contexts/AuthContext";
|
||||
import { TemplateForm } from "@/components/templates/TemplateForm";
|
||||
|
||||
@@ -521,9 +522,18 @@ export function Import() {
|
||||
};
|
||||
|
||||
const handleData = async (data: ImportResult, _file: File, submitOptions: SubmitOptions): Promise<boolean> => {
|
||||
// Hoist for audit logging in catch block
|
||||
const targetEnvironment = submitOptions?.targetEnvironment ?? "prod";
|
||||
const useTestDataSource = Boolean(submitOptions?.useTestDataSource);
|
||||
const targetEndpoint = targetEnvironment === "dev"
|
||||
? "/apiv2-test/product/setup_new"
|
||||
: "/apiv2/product/setup_new";
|
||||
let formattedRows: NormalizedProduct[] = [];
|
||||
let startTime = performance.now();
|
||||
|
||||
try {
|
||||
const rows = ((data.all?.length ? data.all : data.validData) ?? []) as Data<string>[];
|
||||
const formattedRows: NormalizedProduct[] = rows.map((row) => {
|
||||
formattedRows = rows.map((row) => {
|
||||
const baseValues = importFields.reduce((acc, field) => {
|
||||
const rawRow = row as Record<string, DataValue>;
|
||||
const fieldKey = field.key as ImportFieldKey;
|
||||
@@ -582,12 +592,14 @@ export function Import() {
|
||||
return true;
|
||||
}
|
||||
|
||||
startTime = performance.now();
|
||||
const response = await submitNewProducts({
|
||||
products: formattedRows,
|
||||
environment: submitOptions?.targetEnvironment ?? "prod",
|
||||
useTestDataSource: Boolean(submitOptions?.useTestDataSource),
|
||||
environment: targetEnvironment,
|
||||
useTestDataSource,
|
||||
employeeId: user?.id ?? undefined,
|
||||
});
|
||||
const durationMs = Math.round(performance.now() - startTime);
|
||||
|
||||
const isSuccess = response.success;
|
||||
const defaultFailureMessage = "Failed to submit products. Please review and try again.";
|
||||
@@ -620,6 +632,24 @@ export function Import() {
|
||||
};
|
||||
}
|
||||
|
||||
// Audit log — fire-and-forget, never blocks the UI
|
||||
const auditPayload = extractBackendPayload(normalizedResponse.data);
|
||||
createImportAuditLog({
|
||||
user_id: user?.id ?? 0,
|
||||
username: user?.username,
|
||||
product_count: formattedRows.length,
|
||||
request_payload: formattedRows,
|
||||
environment: targetEnvironment,
|
||||
target_endpoint: targetEndpoint,
|
||||
use_test_data_source: useTestDataSource,
|
||||
success: isSuccess,
|
||||
response_payload: normalizedResponse,
|
||||
error_message: isSuccess ? undefined : (resolvedFailureMessage ?? defaultFailureMessage),
|
||||
created_count: auditPayload.created.length,
|
||||
errored_count: auditPayload.errored.length,
|
||||
duration_ms: durationMs,
|
||||
});
|
||||
|
||||
setResumeStepState(undefined);
|
||||
setImportOutcome({
|
||||
submittedProducts: formattedRows.map((product) => ({ ...product })),
|
||||
@@ -641,6 +671,20 @@ export function Import() {
|
||||
|
||||
return isSuccess;
|
||||
} catch (error) {
|
||||
// Audit log for thrown errors (network failures, parse errors, etc.)
|
||||
createImportAuditLog({
|
||||
user_id: user?.id ?? 0,
|
||||
username: user?.username,
|
||||
product_count: formattedRows.length,
|
||||
request_payload: formattedRows,
|
||||
environment: targetEnvironment,
|
||||
target_endpoint: targetEndpoint,
|
||||
use_test_data_source: useTestDataSource,
|
||||
success: false,
|
||||
error_message: error instanceof Error ? error.message : "Unknown error",
|
||||
duration_ms: Math.round(performance.now() - startTime),
|
||||
});
|
||||
|
||||
console.error("Import error:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to import data. Please try again.";
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
@@ -20,7 +22,7 @@ import {
|
||||
import { ProductSearch } from "@/components/product-editor/ProductSearch";
|
||||
import { ProductEditForm, LAYOUT_ICONS } from "@/components/product-editor/ProductEditForm";
|
||||
import type { LayoutMode } from "@/components/product-editor/ProductEditForm";
|
||||
import type { SearchProduct, FieldOptions, FieldOption, LineOption, LandingExtra } from "@/components/product-editor/types";
|
||||
import type { SearchProduct, FieldOptions, FieldOption, LineOption, LandingExtra, ProductImage } from "@/components/product-editor/types";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
const PER_PAGE = 20;
|
||||
@@ -169,15 +171,55 @@ export default function ProductEditor() {
|
||||
const [landingExtras, setLandingExtras] = useState<Record<string, LandingExtra[]>>({});
|
||||
const [isLoadingExtras, setIsLoadingExtras] = useState(false);
|
||||
const [activeLandingItem, setActiveLandingItem] = useState<string | null>(null);
|
||||
const [viewingFeaturedExtra, setViewingFeaturedExtra] = useState<LandingExtra | null>(null);
|
||||
const [newFeedOnly, setNewFeedOnly] = useState(false);
|
||||
const [preorderFeedOnly, setPreorderFeedOnly] = useState(false);
|
||||
const [lineNewOnly, setLineNewOnly] = useState(false);
|
||||
const [linePreorderOnly, setLinePreorderOnly] = useState(false);
|
||||
|
||||
// Abort controller for cancelling in-flight product requests
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const totalPages = Math.ceil(allProducts.length / PER_PAGE);
|
||||
const products = useMemo(
|
||||
() => allProducts.slice((page - 1) * PER_PAGE, page * PER_PAGE),
|
||||
[allProducts, page]
|
||||
const filteredProducts = useMemo(() => {
|
||||
if (viewingFeaturedExtra && activeTab === "new" && newFeedOnly) {
|
||||
return allProducts.filter((p) => p.is_new);
|
||||
}
|
||||
if (viewingFeaturedExtra && activeTab === "preorder" && preorderFeedOnly) {
|
||||
return allProducts.filter((p) => p.is_preorder);
|
||||
}
|
||||
if (activeTab === "by-line" && (lineNewOnly || linePreorderOnly)) {
|
||||
return allProducts.filter((p) =>
|
||||
(lineNewOnly && p.is_new) || (linePreorderOnly && p.is_preorder)
|
||||
);
|
||||
}
|
||||
return allProducts;
|
||||
}, [allProducts, viewingFeaturedExtra, activeTab, newFeedOnly, preorderFeedOnly, lineNewOnly, linePreorderOnly]);
|
||||
|
||||
const totalPages = Math.ceil(filteredProducts.length / PER_PAGE);
|
||||
const products = useMemo(
|
||||
() => filteredProducts.slice((page - 1) * PER_PAGE, page * PER_PAGE),
|
||||
[filteredProducts, page]
|
||||
);
|
||||
|
||||
// Batch-fetch images for the current page of products
|
||||
const [batchImages, setBatchImages] = useState<Record<number, ProductImage[]>>({});
|
||||
useEffect(() => {
|
||||
if (products.length === 0) return;
|
||||
const pids = products.map((p) => p.pid);
|
||||
const controller = new AbortController();
|
||||
axios
|
||||
.get("/api/import/product-images-batch", {
|
||||
params: { pids: pids.join(",") },
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((res) => {
|
||||
setBatchImages((prev) => ({ ...prev, ...res.data }));
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!axios.isCancel(e)) console.error("Failed to batch-load images", e);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [products]);
|
||||
|
||||
useEffect(() => {
|
||||
axios
|
||||
@@ -308,6 +350,9 @@ export default function ProductEditor() {
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setActiveLandingItem(extra.path);
|
||||
setViewingFeaturedExtra(extra);
|
||||
setNewFeedOnly(false);
|
||||
setPreorderFeedOnly(false);
|
||||
setAllProducts([]);
|
||||
setIsLoadingProducts(true);
|
||||
try {
|
||||
@@ -331,6 +376,11 @@ export default function ProductEditor() {
|
||||
setActiveTab(tab);
|
||||
setQueryStatus(null);
|
||||
setQueryId("");
|
||||
setViewingFeaturedExtra(null);
|
||||
setNewFeedOnly(false);
|
||||
setPreorderFeedOnly(false);
|
||||
setLineNewOnly(false);
|
||||
setLinePreorderOnly(false);
|
||||
if (tab === "new" && loadedTab !== "new") {
|
||||
setLoadedTab("new");
|
||||
loadFeedProducts("new-products", "new");
|
||||
@@ -356,6 +406,8 @@ export default function ProductEditor() {
|
||||
abortRef.current = controller;
|
||||
setAllProducts([]);
|
||||
setIsLoadingProducts(true);
|
||||
setLineNewOnly(false);
|
||||
setLinePreorderOnly(false);
|
||||
try {
|
||||
const params: Record<string, string> = { company: lineCompany, line: lineLine };
|
||||
if (lineSubline) params.subline = lineSubline;
|
||||
@@ -634,10 +686,26 @@ export default function ProductEditor() {
|
||||
</div>
|
||||
)}
|
||||
{renderLandingExtras("new")}
|
||||
{viewingFeaturedExtra && activeTab === "new" && !isLoadingProducts && allProducts.length > 0 && !allProducts.every((p) => p.is_new) && (
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
<Switch
|
||||
id="new-only"
|
||||
checked={newFeedOnly}
|
||||
onCheckedChange={(checked) => {
|
||||
setNewFeedOnly(checked);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="new-only" className="text-sm text-muted-foreground">Show only new products</Label>
|
||||
{newFeedOnly && (
|
||||
<span className="text-xs text-muted-foreground">{filteredProducts.length} of {allProducts.length}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isLoadingProducts && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading new products...
|
||||
Loading products...
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
@@ -650,10 +718,26 @@ export default function ProductEditor() {
|
||||
</div>
|
||||
)}
|
||||
{renderLandingExtras("preorder")}
|
||||
{viewingFeaturedExtra && activeTab === "preorder" && !isLoadingProducts && allProducts.length > 0 && !allProducts.every((p) => p.is_preorder) && (
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
<Switch
|
||||
id="preorder-only"
|
||||
checked={preorderFeedOnly}
|
||||
onCheckedChange={(checked) => {
|
||||
setPreorderFeedOnly(checked);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="preorder-only" className="text-sm text-muted-foreground">Show only pre-order products</Label>
|
||||
{preorderFeedOnly && (
|
||||
<span className="text-xs text-muted-foreground">{filteredProducts.length} of {allProducts.length}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isLoadingProducts && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading pre-order products...
|
||||
Loading products...
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
@@ -662,7 +746,7 @@ export default function ProductEditor() {
|
||||
{isLoadingProducts && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading hidden recently-created products...
|
||||
Loading products...
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
@@ -709,7 +793,40 @@ export default function ProductEditor() {
|
||||
{isLoadingProducts && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-3">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading line products...
|
||||
Loading products...
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingProducts && allProducts.length > 0 && activeTab === "by-line" && (allProducts.some((p) => p.is_new && !p.is_preorder) || allProducts.some((p) => p.is_preorder)) && (
|
||||
<div className="flex items-center gap-4 mt-3">
|
||||
{!allProducts.every((p) => p.is_new) && allProducts.some((p) => p.is_new) && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="line-new-only"
|
||||
checked={lineNewOnly}
|
||||
onCheckedChange={(checked) => {
|
||||
setLineNewOnly(checked);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="line-new-only" className="text-sm text-muted-foreground">Show only new products</Label>
|
||||
</div>
|
||||
)}
|
||||
{!allProducts.every((p) => p.is_preorder) && allProducts.some((p) => p.is_preorder) && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="line-preorder-only"
|
||||
checked={linePreorderOnly}
|
||||
onCheckedChange={(checked) => {
|
||||
setLinePreorderOnly(checked);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="line-preorder-only" className="text-sm text-muted-foreground">Show only pre-order</Label>
|
||||
</div>
|
||||
)}
|
||||
{(lineNewOnly || linePreorderOnly) && (
|
||||
<span className="text-xs text-muted-foreground">{filteredProducts.length} of {allProducts.length}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
@@ -725,6 +842,7 @@ export default function ProductEditor() {
|
||||
product={product}
|
||||
fieldOptions={fieldOptions}
|
||||
layoutMode={layoutMode}
|
||||
initialImages={batchImages[product.pid]}
|
||||
onClose={() => handleRemoveProduct(product.pid)}
|
||||
/>
|
||||
))}
|
||||
|
||||
43
inventory/src/services/importAuditLogApi.ts
Normal file
43
inventory/src/services/importAuditLogApi.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Import Audit Log API Service
|
||||
*
|
||||
* Logs every product import submission to a permanent audit trail.
|
||||
* Fire-and-forget by default — callers should not block on the result.
|
||||
*/
|
||||
|
||||
const BASE_URL = '/api/import-audit-log';
|
||||
|
||||
export interface ImportAuditLogEntry {
|
||||
user_id: number;
|
||||
username?: string;
|
||||
product_count: number;
|
||||
request_payload: unknown;
|
||||
environment: 'dev' | 'prod';
|
||||
target_endpoint?: string;
|
||||
use_test_data_source?: boolean;
|
||||
success: boolean;
|
||||
response_payload?: unknown;
|
||||
error_message?: string;
|
||||
created_count?: number;
|
||||
errored_count?: number;
|
||||
session_id?: number | null;
|
||||
duration_ms?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an audit log entry to the backend.
|
||||
* Designed to be fire-and-forget — errors are logged but never thrown
|
||||
* so that a logging failure never blocks the user's import flow.
|
||||
*/
|
||||
export async function createImportAuditLog(entry: ImportAuditLogEntry): Promise<void> {
|
||||
try {
|
||||
await fetch(BASE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(entry),
|
||||
});
|
||||
} catch (error) {
|
||||
// Never throw — audit logging should not disrupt the import flow
|
||||
console.error('Failed to write import audit log:', error);
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user