loreal-spec-tool/server/index.js
Phil Dore 21fcf63431 Initial commit — L'Oréal Spec Tool
238 specs (CH/CZ/DE/NORDICS), dark/light theme, cascading filters,
side-by-side comparison, checklist export, admin panel, bulk import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 18:39:10 +01:00

152 lines
7.9 KiB
JavaScript

'use strict';
const express = require('express');
const path = require('path');
const fs = require('fs');
const multer = require('multer');
const XLSX = require('xlsx');
const { v4: uuidv4 } = require('uuid');
const app = express();
const PORT = process.env.ADMIN_TOKEN || 3101;
const ADMIN_TOKEN= process.env.ADMIN_TOKEN || 'loreal2024';
const STATIC_DIR = path.join(__dirname, '..');
const SPECS_FILE = path.join(STATIC_DIR, 'specs.json');
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 20 * 1024 * 1024 } });
app.use(express.json({ limit: '10mb' }));
app.set('trust proxy', 1);
// ── Auth middleware ───────────────────────────────────────────────────────────
function requireAdmin(req, res, next) {
if (req.headers['x-admin-token'] !== ADMIN_TOKEN) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
}
// ── Health ────────────────────────────────────────────────────────────────────
app.get('/api/health', (_req, res) => res.json({ status: 'ok', specs: loadSpecs().specs.length }));
// ── Get specs ─────────────────────────────────────────────────────────────────
app.get('/api/specs', (_req, res) => res.json(loadSpecs()));
// ── Save specs ────────────────────────────────────────────────────────────────
app.post('/api/specs', requireAdmin, (req, res) => {
const data = req.body;
if (!data || !Array.isArray(data.specs)) return res.status(400).json({ error: 'Invalid payload' });
try {
fs.writeFileSync(SPECS_FILE, JSON.stringify(data, null, 2), 'utf8');
res.json({ ok: true, totalSpecs: data.specs.length });
} catch (e) {
console.error('Write error:', e);
res.status(500).json({ error: 'Failed to save specs' });
}
});
// ── Parse uploaded file (Excel or CSV) ───────────────────────────────────────
app.post('/api/import/parse', requireAdmin, upload.single('file'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
try {
const wb = XLSX.read(req.file.buffer, { type: 'buffer' });
const ws = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json(ws, { defval: '' });
// Normalise: try row 0 as headers, if it looks like a title row skip it
let dataRows = rows;
if (rows.length && typeof rows[0] === 'object') {
const firstKey = Object.keys(rows[0])[0] || '';
// If first row looks like a title (no COUNTRY/RETAILER key), try raw with header row
if (!firstKey.toUpperCase().includes('COUNTRY') && !firstKey.toUpperCase().includes('RETAILER')) {
const raw = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' });
// Find the header row (first row that contains COUNTRY or RETAILER)
let headerIdx = 0;
for (let i = 0; i < Math.min(5, raw.length); i++) {
const rowStr = raw[i].join(' ').toUpperCase();
if (rowStr.includes('COUNTRY') || rowStr.includes('RETAILER')) { headerIdx = i; break; }
}
const headers = raw[headerIdx].map(h => String(h).trim());
dataRows = raw.slice(headerIdx + 1)
.filter(r => r.some(c => c !== ''))
.map(r => Object.fromEntries(headers.map((h, i) => [h, String(r[i] || '').trim()])));
}
}
const today = new Date().toISOString().split('T')[0];
const parsed = dataRows
.filter(r => getField(r, ['RETAILER', 'retailer', 'Retailer']) ||
getField(r, ['FORMAT', 'format', 'Format', 'FORMAT NAME', 'format name']))
.map(r => {
const fileTypesRaw = getField(r, ['FILE TYPE', 'file type', 'File Type', 'fileType', 'file_type', 'FILE_TYPE']);
return {
id: uuidv4(),
country: getField(r, ['COUNTRY', 'country', 'Country']),
retailer: getField(r, ['RETAILER', 'retailer', 'Retailer']),
contentGrouping: getField(r, ['ECOM CONTENT GROUPING', 'Content Grouping', 'contentGrouping', 'content_grouping', 'CONTENT GROUPING', 'category', 'Category']),
division: getField(r, ['DIVISION', 'division', 'Division']),
format: getField(r, ['FORMAT', 'format', 'Format', 'FORMAT NAME', 'format name', 'Format Name']),
dimensions: getField(r, ['ASSET DIMENSION', 'dimensions', 'Dimensions', 'dimension', 'size', 'Size', 'DIMENSIONS']),
maxWeight: getField(r, ['ASSET WEIGHT', 'maxWeight', 'max weight', 'Max Weight', 'file size', 'File Size', 'MAX WEIGHT']),
fileTypes: splitFileTypes(fileTypesRaw),
fileTypesRaw,
maxAssets: getField(r, ['MAX NUMBER OF ASSETS', 'maxAssets', 'max assets', 'Max Assets', 'MAX ASSETS']),
guidelines: getField(r, ['RETAILER ASSET GUIDELINES', 'guidelines', 'Guidelines', 'GUIDELINES', 'notes', 'Notes']),
deliveryMethod: getField(r, ['DELIVERY METHOD FOR SYNDICATION', 'deliveryMethod', 'delivery method', 'Delivery Method']),
deliveryDetail: getField(r, ['DETAILED', 'deliveryDetail', 'delivery detail', 'Delivery Detail', 'delivery', 'Delivery']),
handledBy: getField(r, ['HANDLED BY (WIP)', 'handledBy', 'handled by', 'Handled By']),
namingConvention:getField(r, ['FILE NAMING CONVENTION FOR RETAILER', 'namingConvention', 'naming convention', 'Naming Convention', 'file naming', 'File Naming']),
uploadLink: getField(r, ['LINKS FOR UPLOAD', 'uploadLink', 'upload link', 'Upload Link']),
imageExample: getField(r, ['IMAGE EXAMPLE', 'imageExample', 'image example', 'Image Example']),
notes: getField(r, ['notes', 'Notes', 'NOTES', 'additional notes', 'Additional Notes']),
updatedAt: today,
lastVerified: today,
};
});
res.json({ ok: true, parsed, count: parsed.length });
} catch (e) {
console.error('Parse error:', e);
res.status(500).json({ error: `Failed to parse file: ${e.message}` });
}
});
// ── Static files ──────────────────────────────────────────────────────────────
app.use(express.static(STATIC_DIR, { index: 'index.html' }));
app.get('*', (_req, res) => res.sendFile(path.join(STATIC_DIR, 'index.html')));
app.use((err, _req, res, _next) => {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
});
app.listen(process.env.PORT || 3101, () => {
console.log(`L'Oréal Spec Tool running on port ${process.env.PORT || 3101}`);
});
// ── Helpers ───────────────────────────────────────────────────────────────────
function loadSpecs() {
try { return JSON.parse(fs.readFileSync(SPECS_FILE, 'utf8')); }
catch { return { specs: [], meta: {} }; }
}
function getField(row, keys) {
for (const k of keys) {
if (row[k] !== undefined && row[k] !== null) {
const v = String(row[k]).trim();
if (v && !['NO INFO', 'N/A', '-'].includes(v)) return v;
}
}
return '';
}
function splitFileTypes(raw) {
if (!raw) return [];
const known = new Set(['JPG','JPEG','PNG','TIFF','TIF','PSD','WEBP','SVG','ZIP','PDF','GIF','MP4','MOV','AVI']);
return [...new Set(
raw.toUpperCase().split(/[\s,/|]+/)
.map(p => p.replace(/[^A-Z]/g, ''))
.filter(p => known.has(p))
)];
}