- 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>
360 lines
15 KiB
JavaScript
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);
|
|
}
|
|
})();
|