adeo-maturity-tool/server/index.js
Phil Dore 1f537b8a3b Add Admin tab, Economics overview, Access Log, fix deliverables and gap data
- Merge Economics + Update Data into single Admin tab with sub-nav (Economics | Update Data | Access Log)
- Add Economics tab: per-market financial metrics parsed from Box MD files, card grid matching Markets dashboard style, drill-in detail view for key characteristics; admin-only
- Add Access Log: DB migration (003_access_log), login events recorded in auth.js, admin endpoint + frontend table showing user/action/IP/timestamp
- Fix deliverable downloads: copy PDFs/XLSXs to clients/adeo/deliverables/ and update deliverables.json paths to container-accessible locations
- Patch gap analysis: extract gaps from Question_XX_Analysis.md files in Box for all 8 markets (90 questions patched)
- Mobile: fix card grid minmax, modal max-width uses min(740px,90vw)
- Loading: replace invisible auth-loading with visible spinner overlay
- Add economic.json with key metrics for all 8 markets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 16:35:12 +01:00

360 lines
15 KiB
JavaScript

'use strict';
const express = require('express');
const cookieParser = require('cookie-parser');
const path = require('path');
const fs = require('fs');
const { execFile } = require('child_process');
const multer = require('multer');
const DEV = !process.env.DATABASE_URL;
const { runMigrations, pool } = DEV ? { runMigrations: async () => {}, pool: null } : require('./db');
const { mountAuth, authenticate, requireAdmin } = DEV
? {
mountAuth: () => {},
authenticate: (_req, _res, next) => next(),
requireAdmin: (_req, _res, next) => next(),
}
: require('./auth');
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');
if (!fs.existsSync(TMP_DIR)) fs.mkdirSync(TMP_DIR, { recursive: true });
// Apache fronts with HTTPS; trust X-Forwarded-* so secure cookies work and
// rate-limit sees the real client IP.
app.set('trust proxy', 1);
app.use(express.json({ limit: '10mb' }));
app.use(cookieParser());
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'));
}
async function logImport({ clientId, entityId, userEmail, action, prevScore, newScore, filename }) {
if (!pool) return;
try {
await pool.query(
`INSERT INTO import_log (client_id, entity_id, user_email, action, prev_score, new_score, filename)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[clientId, entityId, userEmail, action, prevScore ?? null, newScore ?? null, filename ?? null]
);
} catch (e) {
console.error('[import_log] Failed to write log entry:', e.message);
}
}
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);
});
}
// ── Public routes ─────────────────────────────────────────────────────────────
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok' });
});
mountAuth(app);
// In dev mode (no DATABASE_URL), auto-authenticate as a local admin
if (DEV) {
app.get('/api/auth/me', (_req, res) => res.json({ id: 0, email: 'dev@local', role: 'admin' }));
}
// ── Authenticated routes (any logged-in user) ─────────────────────────────────
app.get('/api/clients', authenticate, (_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,
translations: cfg.translations || {},
has_translations: !!(cfg.translations && Object.keys(cfg.translations).length > 0),
};
});
res.json(clients);
});
app.get('/api/clients/:id/config', authenticate, (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));
});
app.get('/api/clients/:id/data', authenticate, (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));
});
app.get('/api/clients/:id/deliverables', authenticate, (req, res) => {
const delPath = path.join(CLIENT_DIR, req.params.id, 'deliverables.json');
if (!fs.existsSync(delPath)) return res.json({});
res.json(readJson(delPath));
});
app.get('/api/clients/:id/economic', authenticate, (req, res) => {
const econPath = path.join(CLIENT_DIR, req.params.id, 'economic.json');
if (!fs.existsSync(econPath)) return res.json({});
res.json(readJson(econPath));
});
app.get('/api/admin/access-log', authenticate, requireAdmin, async (req, res) => {
if (!pool) return res.json([]);
try {
const { rows } = await pool.query(
`SELECT user_email, action, detail, ip, logged_at
FROM access_log ORDER BY logged_at DESC LIMIT 200`
);
res.json(rows);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/clients/:id/deliverables/:entityId/:type', authenticate, (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);
});
app.get('/api/clients/:id/export/pdf/:entityId', authenticate, (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);
});
});
// ── Admin-only routes (write/upload) ──────────────────────────────────────────
app.post('/api/clients/:id/data', authenticate, requireAdmin, (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 });
}
});
app.post('/api/clients', authenticate, requireAdmin, (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 });
});
app.post('/api/clients/:id/sync', authenticate, requireAdmin, (req, res) => {
const id = req.params.id.replace(/[^a-zA-Z0-9_-]/g, '');
const cfgPath = path.join(CLIENT_DIR, id, 'config.json');
const datPath = path.join(CLIENT_DIR, id, 'data.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];
// Snapshot scores before sync so we can log what changed
const prevScores = {};
if (fs.existsSync(datPath)) {
const before = readJson(datPath);
(before.entities || []).forEach(e => { prevScores[e.id] = e.overall_score; });
}
execFile('python3', args, { cwd: ROOT, timeout: 120000 }, async (err, stdout, stderr) => {
if (err) {
console.error('Sync failed:', stderr);
return res.status(500).json({ error: 'Sync failed', detail: stderr, log: stdout });
}
// Log an entry for each entity that was synced
if (fs.existsSync(datPath)) {
const after = readJson(datPath);
const entities = entityId
? (after.entities || []).filter(e => e.id === entityId)
: (after.entities || []);
for (const e of entities) {
await logImport({
clientId: id,
entityId: e.id,
userEmail: req.user?.email || 'unknown',
action: 'sync',
prevScore: prevScores[e.id] ?? null,
newScore: e.overall_score,
filename: null,
});
}
}
res.json({ ok: true, log: stdout });
});
});
app.get('/api/clients/:id/activity', authenticate, requireAdmin, async (req, res) => {
const id = req.params.id.replace(/[^a-zA-Z0-9_-]/g, '');
if (!pool) return res.json([]);
try {
const { rows } = await pool.query(
`SELECT entity_id, user_email, action, prev_score, new_score, filename, logged_at
FROM import_log WHERE client_id = $1 ORDER BY logged_at DESC LIMIT 100`,
[id]
);
res.json(rows);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.post('/api/clients/:id/import/file', authenticate, requireAdmin, 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 }, async (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` });
const prevScore = data.entities[idx].overall_score ?? null;
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');
await logImport({
clientId: id,
entityId,
userEmail: req.user?.email || 'unknown',
action: 'import',
prevScore,
newScore: parsed.overall_score,
filename: req.file?.originalname || null,
});
res.json({ ok: true, entity: data.entities.find(e => e.id === entityId) });
});
});
// ── Static frontend (no auth — login page lives here) ─────────────────────────
app.use(express.static(ROOT, { index: 'index.html' }));
app.get('*', (_req, res) => res.sendFile(path.join(ROOT, 'index.html')));
// ── Boot ──────────────────────────────────────────────────────────────────────
(async () => {
try {
await runMigrations();
app.listen(PORT, () => console.log(`Maturity Tool running on http://localhost:${PORT}`));
} catch (e) {
console.error('[boot] Failed to start:', e);
process.exit(1);
}
})();