adeo-maturity-tool/server/index.js
Phil Dore 5eac433d51 Add heatmap, radar chart, sort/group controls, and About tab
- Heatmap matrix: pillar × market colour-coded table in Compare tab
- Radar/spider chart: pure SVG chart in entity detail view
- Sort controls: sort by overall score, pillar, or group on Markets tab
- Group toggle: cluster entity cards by group (Leroy Merlin / Obramat etc.)
- About tab: home screen tab explaining scoring levels, pillars, and features
- /api/clients now returns pillars, scoring, about for About tab rendering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 18:15:05 +01:00

243 lines
10 KiB
JavaScript

'use strict';
const express = require('express');
const path = require('path');
const fs = require('fs');
const { execFile } = require('child_process');
const multer = require('multer');
const app = express();
const PORT = process.env.PORT || 3102;
const ROOT = path.join(__dirname, '..');
const CLIENT_DIR = path.join(ROOT, 'clients');
const TMP_DIR = path.join(ROOT, 'tmp_uploads');
// Ensure tmp upload dir exists
if (!fs.existsSync(TMP_DIR)) fs.mkdirSync(TMP_DIR, { recursive: true });
// Multer — disk storage preserving original extension
const upload = multer({
storage: multer.diskStorage({
destination: TMP_DIR,
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase() || '.csv';
cb(null, `upload_${Date.now()}_${Math.random().toString(36).slice(2)}${ext}`);
},
}),
limits: { fileSize: 30 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const ok = ['.csv', '.xlsx', '.xls'].includes(path.extname(file.originalname).toLowerCase());
cb(ok ? null : new Error('Only CSV and XLSX files are supported'), ok);
},
});
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function listClients() {
if (!fs.existsSync(CLIENT_DIR)) return [];
return fs.readdirSync(CLIENT_DIR).filter(id => {
const cfg = path.join(CLIENT_DIR, id, 'config.json');
const dat = path.join(CLIENT_DIR, id, 'data.json');
return fs.existsSync(cfg) && fs.existsSync(dat);
});
}
// Health
app.get('/api/health', (_req, res) => {
const clients = listClients();
res.json({ status: 'ok', clients });
});
// List clients
app.get('/api/clients', (_req, res) => {
const clients = listClients().map(id => {
const cfg = readJson(path.join(CLIENT_DIR, id, 'config.json'));
const dat = readJson(path.join(CLIENT_DIR, id, 'data.json'));
return {
id: cfg.id,
name: cfg.name,
description: cfg.description,
accent_color: cfg.accent_color,
entity_label: cfg.entity_label,
logo: cfg.logo || null,
entity_count: dat.entities ? dat.entities.length : 0,
generated: dat.generated,
pillars: cfg.pillars || [],
scoring: cfg.scoring || {},
about: cfg.about || null,
};
});
res.json(clients);
});
// Client config
app.get('/api/clients/:id/config', (req, res) => {
const cfgPath = path.join(CLIENT_DIR, req.params.id, 'config.json');
if (!fs.existsSync(cfgPath)) return res.status(404).json({ error: 'Client not found' });
res.json(readJson(cfgPath));
});
// Client data
app.get('/api/clients/:id/data', (req, res) => {
const datPath = path.join(CLIENT_DIR, req.params.id, 'data.json');
if (!fs.existsSync(datPath)) return res.status(404).json({ error: 'Data not found — run: python3 convert_data.py ' + req.params.id });
res.json(readJson(datPath));
});
// Save updated client data (score edits)
app.use(express.json({ limit: '10mb' }));
app.post('/api/clients/:id/data', (req, res) => {
const datPath = path.join(CLIENT_DIR, req.params.id, 'data.json');
if (!fs.existsSync(datPath)) return res.status(404).json({ error: 'Client not found' });
try {
const payload = req.body;
if (!payload || !Array.isArray(payload.entities)) {
return res.status(400).json({ error: 'Invalid payload — expected { entities: [...] }' });
}
fs.writeFileSync(datPath, JSON.stringify(payload, null, 2), 'utf8');
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: 'Failed to save: ' + e.message });
}
});
// Create a new client (writes config.json + empty data.json)
app.post('/api/clients', (req, res) => {
const config = req.body;
if (!config || !config.name) return res.status(400).json({ error: 'name is required' });
const id = String(config.id || config.name)
.toLowerCase().replace(/[^a-z0-9\s-]/g, '').trim()
.replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
if (!id) return res.status(400).json({ error: 'Could not derive a valid client ID' });
const clientDir = path.join(CLIENT_DIR, id);
if (fs.existsSync(clientDir)) return res.status(409).json({ error: `Client "${id}" already exists` });
fs.mkdirSync(clientDir, { recursive: true });
const finalConfig = { ...config, id };
fs.writeFileSync(path.join(clientDir, 'config.json'), JSON.stringify(finalConfig, null, 2), 'utf8');
const emptyData = { generated: new Date().toISOString().slice(0, 10), client_id: id, entities: [] };
fs.writeFileSync(path.join(clientDir, 'data.json'), JSON.stringify(emptyData, null, 2), 'utf8');
res.json({ ok: true, id });
});
// Deliverables manifest (deliverables.json — optional per client)
app.get('/api/clients/:id/deliverables', (req, res) => {
const delPath = path.join(CLIENT_DIR, req.params.id, 'deliverables.json');
if (!fs.existsSync(delPath)) return res.json({});
res.json(readJson(delPath));
});
// Stream a single deliverable file (pdf or xlsx)
app.get('/api/clients/:id/deliverables/:entityId/:type', (req, res) => {
const { id, entityId, type } = req.params;
if (!['pdf', 'xlsx'].includes(type)) return res.status(400).json({ error: 'type must be pdf or xlsx' });
const delPath = path.join(CLIENT_DIR, id, 'deliverables.json');
if (!fs.existsSync(delPath)) return res.status(404).json({ error: 'No deliverables configured for this client' });
const map = readJson(delPath);
const entry = map[entityId];
if (!entry || !entry[type]) return res.status(404).json({ error: `No ${type} deliverable configured for ${entityId}` });
const filePath = entry[type];
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found on disk: ' + path.basename(filePath) });
const ext = path.extname(filePath).toLowerCase();
const mime = ext === '.pdf' ? 'application/pdf'
: ext === '.xlsx' ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
: 'text/csv';
res.setHeader('Content-Type', mime);
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(filePath)}"`);
fs.createReadStream(filePath).pipe(res);
});
// Box sync — re-runs convert_data.py for this client (all entities or a single one)
app.post('/api/clients/:id/sync', (req, res) => {
const id = req.params.id.replace(/[^a-zA-Z0-9_-]/g, '');
const cfgPath = path.join(CLIENT_DIR, id, 'config.json');
if (!fs.existsSync(cfgPath)) return res.status(404).json({ error: 'Client not found' });
const script = path.join(ROOT, 'convert_data.py');
const entityId = (req.body && req.body.entity_id) ? String(req.body.entity_id).replace(/[^a-zA-Z0-9_-]/g, '') : null;
const args = entityId ? [script, id, '--entity', entityId] : [script, id];
execFile('python3', args, { cwd: ROOT, timeout: 120000 }, (err, stdout, stderr) => {
if (err) {
console.error('Sync failed:', stderr);
return res.status(500).json({ error: 'Sync failed', detail: stderr, log: stdout });
}
res.json({ ok: true, log: stdout });
});
});
// File import — parse an uploaded CSV/XLSX and merge into an entity
app.post('/api/clients/:id/import/file', upload.single('file'), (req, res) => {
const id = req.params.id.replace(/[^a-zA-Z0-9_-]/g, '');
const entityId = (req.body.entity_id || '').replace(/[^a-zA-Z0-9_-]/g, '');
const datPath = path.join(CLIENT_DIR, id, 'data.json');
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
if (!entityId) { fs.unlinkSync(req.file.path); return res.status(400).json({ error: 'entity_id required' }); }
if (!fs.existsSync(datPath)) { fs.unlinkSync(req.file.path); return res.status(404).json({ error: 'Client not found' }); }
const script = path.join(ROOT, 'import_file.py');
execFile('python3', [script, id, entityId, req.file.path], { cwd: ROOT, timeout: 30000 }, (err, stdout, stderr) => {
try { fs.unlinkSync(req.file.path); } catch (_) {}
if (err) {
console.error('Import failed:', stderr);
return res.status(500).json({ error: 'Import failed', detail: stderr });
}
let parsed;
try {
parsed = JSON.parse(stdout.trim());
} catch (e) {
return res.status(500).json({ error: 'Could not parse import output', detail: stdout });
}
if (parsed.error) return res.status(400).json(parsed);
const data = readJson(datPath);
const idx = data.entities.findIndex(e => e.id === entityId);
if (idx < 0) return res.status(404).json({ error: `Entity '${entityId}' not found in data.json` });
// Preserve identity fields, replace scores
const existing = data.entities[idx];
data.entities[idx] = {
id: existing.id,
label: existing.label,
short: existing.short,
group: existing.group,
overall_score: parsed.overall_score,
overall_level: parsed.overall_level,
overall_label: parsed.overall_label,
pillars: parsed.pillars,
};
data.generated = new Date().toISOString().slice(0, 10);
data.entities.sort((a, b) => b.overall_score - a.overall_score);
fs.writeFileSync(datPath, JSON.stringify(data, null, 2), 'utf8');
res.json({ ok: true, entity: data.entities.find(e => e.id === entityId) });
});
});
// PDF export — generates via Python/ReportLab and streams back
app.get('/api/clients/:id/export/pdf/:entityId', (req, res) => {
const { id, entityId } = req.params;
const datPath = path.join(CLIENT_DIR, id, 'data.json');
const cfgPath = path.join(CLIENT_DIR, id, 'config.json');
if (!fs.existsSync(datPath) || !fs.existsSync(cfgPath)) {
return res.status(404).json({ error: 'Client not found' });
}
const script = path.join(ROOT, 'pdf_generator.py');
execFile('python3', [script, id, entityId], { cwd: ROOT }, (err, stdout, stderr) => {
if (err) {
console.error('PDF generation failed:', stderr);
return res.status(500).json({ error: 'PDF generation failed', detail: stderr });
}
const pdfPath = path.join(CLIENT_DIR, id, 'exports', `${entityId}_Maturity_Report.pdf`);
if (!fs.existsSync(pdfPath)) {
return res.status(500).json({ error: 'PDF not found after generation' });
}
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${entityId}_Maturity_Report.pdf"`);
fs.createReadStream(pdfPath).pipe(res);
});
});
// Static files
app.use(express.static(ROOT, { index: 'index.html' }));
app.get('*', (_req, res) => res.sendFile(path.join(ROOT, 'index.html')));
app.listen(PORT, () => console.log(`Maturity Tool running on http://localhost:${PORT}`));