'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)) )]; }