/** * DISSG — Decision Intelligence / Kommunstatistik * Comprehensive router — all 20 tickets D-001 to D-020 * Built: 2026-06-05 */ import { Router } from 'express'; import https from 'https'; import http from 'http'; import { createHash } from 'crypto'; import { Pool } from 'pg'; const router = Router(); // ── DB POOL ───────────────────────────────────────────────────────────────── const DISSG_DB = 'postgresql://wavult_admin:efG15aKjqgu7uotZoAiLTRBtBDMoXITxIe9Hi6EB@platform-identity-core.cvi0qcksmsfj.eu-north-1.rds.amazonaws.com:5432/amos'; let _pool = null; function getPool() { if (!_pool) _pool = new Pool({ connectionString: DISSG_DB, ssl: { rejectUnauthorized: false }, max: 5 }); return _pool; } // ── CACHE ──────────────────────────────────────────────────────────────────── const CACHE = new Map(); const TTL_1H = 3600000; const TTL_24H = 86400000; function cached(key, ttl, fn) { const now = Date.now(); const c = CACHE.get(key); if (c && now - c.ts < ttl) return Promise.resolve(c.data); return fn().then(data => { CACHE.set(key, { data, ts: now }); return data; }); } // ── HTTP HELPERS ────────────────────────────────────────────────────────────── function fetchJSON(url, opts = {}) { return new Promise(resolve => { const lib = url.startsWith('https') ? https : http; let settled = false; const done = (v) => { if (!settled) { settled = true; resolve(v); } }; const req = lib.get(url, { headers: { 'User-Agent': 'DISSG/2.0 AAMOS', ...opts.headers } }, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => { try { done(JSON.parse(d)); } catch { done({ error: 'parse', raw: d.slice(0, 200) }); } }); }); req.on('error', e => done({ error: e.message })); req.setTimeout(15000, () => { req.destroy(); done({ error: 'timeout' }); }); }); } // ── WORLDBANK ───────────────────────────────────────────────────────────────── const WB = 'https://api.worldbank.org/v2'; function wb(path) { // Build URL — avoid duplicate params (WB returns error on duplicates) let url = `${WB}/${path}`; const sep = path.includes('?') ? '&' : '?'; if (!path.includes('format=')) url += sep + 'format=json'; if (!path.includes('per_page=')) url += '&per_page=300'; return cached('wb:' + path, TTL_1H, () => fetchJSON(url)); } function extractWB(resp) { if (!Array.isArray(resp) || !resp[1]) return {}; const map = {}; for (const e of resp[1]) { if (e.countryiso3code && e.value != null) map[e.countryiso3code] = e.value; } return map; } function extractWBseries(resp) { if (!Array.isArray(resp) || !resp[1]) return []; return resp[1].filter(e => e.value != null).map(e => ({ year: parseInt(e.date), value: e.value, country: e.countryiso3code })); } // ── RIKSDAGEN ───────────────────────────────────────────────────────────────── function riksdagen(endpoint) { const url = `https://data.riksdagen.se/${endpoint}${endpoint.includes('?') ? '&' : '?'}utformat=json`; return cached('rd:' + endpoint, TTL_1H, () => fetchJSON(url)); } // ── KOLADA ──────────────────────────────────────────────────────────────────── function kolada(path) { return cached('ko:' + path, TTL_1H, () => fetchJSON(`https://api.kolada.se/v3/${path}`) ); } // ── AUDIT LOG ───────────────────────────────────────────────────────────────── async function auditLog(event, data = {}) { try { const pool = getPool(); await pool.query( `INSERT INTO dissg_audit_log (event, data, ts) VALUES ($1, $2, NOW()) ON CONFLICT DO NOTHING`, [event, JSON.stringify(data)] ).catch(() => {}); } catch {} } // ── RATE LIMIT MAP ───────────────────────────────────────────────────────────── const _rl = new Map(); function checkRL(ip, tier = 'free') { const limits = { free: 60, pro: 600, enterprise: 6000 }; const limit = limits[tier] || 60; const now = Date.now(); const min = Math.floor(now / 60000); const key = `${ip}:${min}`; const cnt = (_rl.get(key) || 0) + 1; _rl.set(key, cnt); setTimeout(() => _rl.delete(key), 70000); return cnt <= limit; } // ═══════════════════════════════════════════════════════════════════════════════ // HEALTH // ═══════════════════════════════════════════════════════════════════════════════ router.get('/health', (_, res) => res.json({ ok: true, module: 'DISSG', version: '2.0.0', tickets: 20, sources: ['WorldBank', 'Riksdagen', 'Kolada', 'OECD', 'UN Stats', 'IMF'], ts: new Date().toISOString() })); // ═══════════════════════════════════════════════════════════════════════════════ // D-010 (P0): Global Diagnostic Map API — GeoJSON + scores, 190+ länder // ═══════════════════════════════════════════════════════════════════════════════ router.get('/gdm', async (req, res) => { try { const { getGDMData } = await import('./gdm.mjs'); const data = await getGDMData(); const fmt = req.query.format || 'json'; auditLog('gdm.query', { fmt, ip: req.ip }); if (fmt === 'geojson') { const features = Object.entries(data).map(([iso, d]) => ({ type: 'Feature', properties: { iso, ...d }, geometry: null // client resolves geometry via iso })); return res.json({ type: 'FeatureCollection', features, count: features.length, source: 'World Bank Open Data', ts: new Date().toISOString() }); } const countries = Object.keys(data).length; res.json({ ok: true, countries, data, source: 'World Bank Open Data', evidence_grade: 'E3', ts: new Date().toISOString() }); } catch (e) { res.status(500).json({ error: e.message }); } }); router.get('/gdm/geojson', async (req, res) => { try { const { getGDMData } = await import('./gdm.mjs'); const data = await getGDMData(); const features = Object.entries(data).map(([iso, d]) => ({ type: 'Feature', id: iso, properties: { iso, ...d }, geometry: null })); res.json({ type: 'FeatureCollection', features, count: features.length, source: 'World Bank Open Data', evidence_grade: 'E3', ts: new Date().toISOString() }); } catch (e) { res.status(500).json({ error: e.message }); } }); // ═══════════════════════════════════════════════════════════════════════════════ // D-009: Global Master Index Motor — komposit 6 kategorier per land // ═══════════════════════════════════════════════════════════════════════════════ const GMI_INDICATORS = { economy: { id: 'NY.GDP.PCAP.CD', name: 'GDP per capita (USD)', max: 70000 }, health: { id: 'SP.DYN.LE00.IN', name: 'Life expectancy (years)', max: 87 }, education: { id: 'SE.TER.ENRR', name: 'Tertiary enrollment (%)', max: 100 }, governance: { id: 'SE.SEC.ENRR', name: 'Secondary enrollment (%) proxy', max: 130 }, environment:{ id: 'EG.USE.PCAP.KG.OE', name: 'Energy efficiency (inv kg oil)', max: 8000, invert: true }, digital: { id: 'IT.NET.USER.ZS', name: 'Internet users %', max: 100 } }; router.get('/gmi', async (req, res) => { try { const country = (req.query.country || 'SWE').toUpperCase(); const year = req.query.year || ''; const mrv = year ? `date=${year}` : 'mrv=1'; const fetches = await Promise.all( Object.entries(GMI_INDICATORS).map(async ([cat, ind]) => { const path = `country/${country}/indicator/${ind.id}?${mrv}`; const resp = await wb(path); const val = Array.isArray(resp) && resp[1]?.[0]?.value; let score = null; if (val != null) { if (ind.center) { score = Math.round(((val - ind.min) / (ind.max - ind.min)) * 100); } else if (ind.invert) { score = Math.round(Math.max(0, 100 - (val / ind.max) * 100)); } else { score = Math.round(Math.min(100, (val / ind.max) * 100)); } } return { cat, name: ind.name, raw: val, score }; }) ); const valid = fetches.filter(f => f.score != null); const overall = valid.length ? Math.round(valid.reduce((s, f) => s + f.score, 0) / valid.length) : null; const categories = {}; fetches.forEach(f => { categories[f.cat] = { name: f.name, raw: f.raw, score: f.score }; }); res.json({ ok: true, country, overall, categories, evidence_grade: 'E3', source: 'World Bank Open Data', ts: new Date().toISOString() }); auditLog('gmi.query', { country }); } catch (e) { res.status(500).json({ error: e.message }); } }); router.get('/gmi/all', async (req, res) => { try { // Fetch top indicators for all countries const [gdp, life, internet, literacy] = await Promise.all([ wb('country/all/indicator/NY.GDP.PCAP.CD?mrv=1'), wb('country/all/indicator/SP.DYN.LE00.IN?mrv=1'), wb('country/all/indicator/IT.NET.USER.ZS?mrv=1'), wb('country/all/indicator/SE.ADT.LITR.ZS?mrv=1') ]); const gdpMap = extractWB(gdp), lifeMap = extractWB(life), netMap = extractWB(internet), litMap = extractWB(literacy); const results = {}; const allISOs = new Set([...Object.keys(gdpMap), ...Object.keys(lifeMap)]); for (const iso of allISOs) { if (iso.length !== 3) continue; const econ = gdpMap[iso] ? Math.min(100, Math.round((gdpMap[iso] / 70000) * 100)) : null; const hlth = lifeMap[iso] ? Math.min(100, Math.round((lifeMap[iso] / 87) * 100)) : null; const digi = netMap[iso] ? Math.round(netMap[iso]) : null; const educ = litMap[iso] ? Math.round(litMap[iso]) : null; const scores = [econ, hlth, digi, educ].filter(x => x != null); if (scores.length >= 2) { results[iso] = { overall: Math.round(scores.reduce((a, b) => a + b) / scores.length), economy: econ, health: hlth, digital: digi, education: educ }; } } res.json({ ok: true, countries: Object.keys(results).length, data: results, source: 'World Bank', evidence_grade: 'E3', ts: new Date().toISOString() }); } catch (e) { res.status(500).json({ error: e.message }); } }); // ═══════════════════════════════════════════════════════════════════════════════ // D-002: Oscilloscope Trendanalys — tidsserie per indikator // ═══════════════════════════════════════════════════════════════════════════════ const COMMON_INDICATORS = { gdp_per_capita: 'NY.GDP.PCAP.CD', gdp_growth: 'NY.GDP.MKTP.KD.ZG', life_expectancy: 'SP.DYN.LE00.IN', unemployment: 'SL.UEM.TOTL.ZS', inflation: 'FP.CPI.TOTL.ZG', internet_users: 'IT.NET.USER.ZS', co2_per_capita: 'EN.ATM.CO2E.PC', education_expenditure: 'SE.XPD.TOTL.GD.ZS', health_expenditure: 'SH.XPD.CHEX.GD.ZS', gini: 'SI.POV.GINI', population: 'SP.POP.TOTL', trade_gdp: 'NE.TRD.GNFS.ZS' }; router.get('/trends/:indicator', async (req, res) => { try { const { indicator } = req.params; const country = (req.query.country || 'SWE').toUpperCase(); const fromYear = parseInt(req.query.from || '2000'); const toYear = parseInt(req.query.to || '2023'); const wbId = COMMON_INDICATORS[indicator] || indicator; const resp = await wb(`country/${country}/indicator/${wbId}?date=${fromYear}:${toYear}&per_page=50`); const series = extractWBseries(resp); series.sort((a, b) => a.year - b.year); // Trendlinje (linjär regression) const n = series.length; let trend = null; if (n >= 3) { const xs = series.map((_, i) => i); const ys = series.map(s => s.value); const mx = xs.reduce((a, b) => a + b) / n, my = ys.reduce((a, b) => a + b) / n; const num = xs.reduce((s, x, i) => s + (x - mx) * (ys[i] - my), 0); const den = xs.reduce((s, x) => s + (x - mx) ** 2, 0); const slope = den ? num / den : 0; trend = { slope, direction: slope > 0.01 ? 'up' : slope < -0.01 ? 'down' : 'flat' }; } res.json({ ok: true, indicator: wbId, indicator_alias: indicator, country, from: fromYear, to: toYear, series, trend, count: series.length, source: 'World Bank', evidence_grade: 'E3', ts: new Date().toISOString() }); } catch (e) { res.status(500).json({ error: e.message }); } }); router.get('/trends', (req, res) => { res.json({ ok: true, available_indicators: Object.keys(COMMON_INDICATORS), usage: 'GET /trends/:indicator?country=SWE&from=2000&to=2023' }); }); // ═══════════════════════════════════════════════════════════════════════════════ // D-003: Beslutstidslinje — Riksdagen API → beslut per datum // ═══════════════════════════════════════════════════════════════════════════════ router.get('/decisions', async (req, res) => { try { const { from, to, search, sz = 20 } = req.query; let endpoint = `dokumentlista/?doktyp=bet&sz=${Math.min(parseInt(sz), 50)}`; if (search) endpoint += `&sok=${encodeURIComponent(search)}`; if (from) endpoint += `&from=${from}`; if (to) endpoint += `&tom=${to}`; const data = await riksdagen(endpoint); const docs = data?.dokumentlista?.dokument || []; const decisions = docs.map(d => ({ id: d.id, title: d.titel, organ: d.organ, date: d.datum, type: d.typ, status: d.status, url: `https://riksdagen.se${d.dokumentstatus_url_xml?.replace('.xml', '') || ''}`, summary: d.summary || d.notisrubrik })); decisions.sort((a, b) => new Date(b.date) - new Date(a.date)); res.json({ ok: true, count: decisions.length, decisions, source: 'Riksdagen Open Data', evidence_grade: 'E3', ts: new Date().toISOString() }); } catch (e) { res.status(500).json({ error: e.message }); } }); router.get('/decisions/votes', async (req, res) => { try { const sz = Math.min(parseInt(req.query.sz || '20'), 50); const data = await riksdagen(`voteringlista/?sz=${sz}`); const votes = data?.voteringlista?.votering || []; res.json({ ok: true, count: votes.length, votes, source: 'Riksdagen Open Data', ts: new Date().toISOString() }); } catch (e) { res.status(500).json({ error: e.message }); } }); // ═══════════════════════════════════════════════════════════════════════════════ // D-004: Landsportföljer — WorldBank-indikatorer per land // ═══════════════════════════════════════════════════════════════════════════════ const PORTFOLIO_INDICATORS = [ 'NY.GDP.PCAP.CD', 'SP.DYN.LE00.IN', 'SL.UEM.TOTL.ZS', 'FP.CPI.TOTL.ZG', 'IT.NET.USER.ZS', 'EN.ATM.CO2E.PC', 'SE.XPD.TOTL.GD.ZS', 'SH.XPD.CHEX.GD.ZS', 'NY.GDP.MKTP.KD.ZG', 'SP.POP.TOTL', 'SI.POV.GINI', 'SE.ADT.LITR.ZS' ]; const IND_LABELS = { 'NY.GDP.PCAP.CD': 'GDP per capita (USD)', 'SP.DYN.LE00.IN': 'Life expectancy (years)', 'SL.UEM.TOTL.ZS': 'Unemployment (%)', 'FP.CPI.TOTL.ZG': 'Inflation (%)', 'IT.NET.USER.ZS': 'Internet users (%)', 'EN.ATM.CO2E.PC': 'CO2 per capita (t)', 'SE.XPD.TOTL.GD.ZS': 'Education expenditure (% GDP)', 'SH.XPD.CHEX.GD.ZS': 'Health expenditure (% GDP)', 'NY.GDP.MKTP.KD.ZG': 'GDP growth (%)', 'SP.POP.TOTL': 'Population', 'SI.POV.GINI': 'Gini coefficient', 'SE.ADT.LITR.ZS': 'Literacy rate (%)' }; router.get('/portfolio/:country', async (req, res) => { try { const country = req.params.country.toUpperCase(); const year = req.query.year || ''; const mrv = year ? `date=${year}` : 'mrv=3'; const results = await Promise.all( PORTFOLIO_INDICATORS.map(async ind => { const resp = await wb(`country/${country}/indicator/${ind}?${mrv}`); const pts = Array.isArray(resp) && resp[1] ? resp[1].filter(e => e.value != null) : []; const latest = pts[0]; return { indicator: ind, label: IND_LABELS[ind] || ind, latest_value: latest?.value, latest_year: latest?.date, history: pts.slice(0, 5).map(p => ({ year: p.date, value: p.value })) }; }) ); const country_info_resp = await wb(`country/${country}?format=json`); const info = Array.isArray(country_info_resp) && country_info_resp[1]?.[0]; res.json({ ok: true, country, country_info: info ? { name: info.name, region: info.region?.value, income_level: info.incomeLevel?.value, capital: info.capitalCity } : null, portfolio: results, source: 'World Bank Open Data', evidence_grade: 'E3', ts: new Date().toISOString() }); } catch (e) { res.status(500).json({ error: e.message }); } }); // ═══════════════════════════════════════════════════════════════════════════════ // D-001: Kausalgrafer API — orsaksanalys, WorldBank + Riksdagen // ═══════════════════════════════════════════════════════════════════════════════ const CAUSAL_PAIRS = [ { cause: 'SE.XPD.TOTL.GD.ZS', effect: 'SE.ADT.LITR.ZS', label: 'Education spending → Literacy' }, { cause: 'SH.XPD.CHEX.GD.ZS', effect: 'SP.DYN.LE00.IN', label: 'Health spending → Life expectancy' }, { cause: 'NY.GDP.PCAP.CD', effect: 'IT.NET.USER.ZS', label: 'GDP per capita → Internet access' }, { cause: 'NY.GDP.PCAP.CD', effect: 'SL.UEM.TOTL.ZS', label: 'GDP growth → Unemployment' }, { cause: 'EN.ATM.CO2E.PC', effect: 'SP.DYN.LE00.IN', label: 'CO2 emissions → Life expectancy' }, { cause: 'SI.POV.GINI', effect: 'SL.UEM.TOTL.ZS', label: 'Inequality → Unemployment' } ]; function pearsonCorr(xs, ys) { const n = xs.length; if (n < 3) return null; const mx = xs.reduce((a, b) => a + b) / n, my = ys.reduce((a, b) => a + b) / n; const num = xs.reduce((s, x, i) => s + (x - mx) * (ys[i] - my), 0); const den = Math.sqrt(xs.reduce((s, x) => s + (x - mx) ** 2, 0) * ys.reduce((s, y) => s + (y - my) ** 2, 0)); return den ? parseFloat((num / den).toFixed(3)) : null; } router.get('/causal', async (req, res) => { try { const country = (req.query.country || 'all').toUpperCase(); const from = req.query.from || '2000'; const to = req.query.to || '2022'; const countryPath = country === 'ALL' ? 'all' : country; const nodes = new Set(); const edges = []; const indIds = [...new Set(CAUSAL_PAIRS.flatMap(p => [p.cause, p.effect]))]; const seriesMap = {}; await Promise.all(indIds.map(async ind => { const resp = await wb(`country/${countryPath}/indicator/${ind}?date=${from}:${to}&per_page=200`); seriesMap[ind] = extractWBseries(resp); nodes.add(ind); })); for (const pair of CAUSAL_PAIRS) { const xs = seriesMap[pair.cause], ys = seriesMap[pair.effect]; // Align by year const yearMap = {}; xs.forEach(p => { if (!yearMap[p.year]) yearMap[p.year] = {}; yearMap[p.year].x = p.value; }); ys.forEach(p => { if (!yearMap[p.year]) yearMap[p.year] = {}; yearMap[p.year].y = p.value; }); const aligned = Object.values(yearMap).filter(p => p.x != null && p.y != null); const corr = pearsonCorr(aligned.map(p => p.x), aligned.map(p => p.y)); edges.push({ cause: pair.cause, effect: pair.effect, label: pair.label, correlation: corr, strength: corr ? Math.abs(corr) : null, direction: corr > 0.3 ? 'positive' : corr < -0.3 ? 'negative' : 'weak', n_observations: aligned.length, evidence_grade: aligned.length >= 10 ? 'E2' : 'E3' }); } edges.sort((a, b) => (b.strength || 0) - (a.strength || 0)); res.json({ ok: true, country, from, to, nodes: [...nodes].map(id => ({ id, label: IND_LABELS[id] || id })), edges, causal_analysis: 'Pearson correlation (Granger pending)', source: 'World Bank Open Data', ts: new Date().toISOString() }); } catch (e) { res.status(500).json({ error: e.message }); } }); // ═══════════════════════════════════════════════════════════════════════════════ // D-012: OECD API Integration — hälsa/utbildning/arbete/miljö // ═══════════════════════════════════════════════════════════════════════════════ const OECD_DATASETS = { health: { id: 'HEALTH_STAT', label: 'Health Statistics', endpoint: 'HEALTH_STAT/CIRC+NEPH+CANC/DTHRTE/AUS+AUT+BEL+CAN+CHL+CZE+DNK+EST+FIN+FRA+DEU+GRC+HUN+ISL+IRL+ISR+ITA+JPN+KOR+LVA+LTU+LUX+MEX+NLD+NZL+NOR+POL+PRT+SVK+SVN+ESP+SWE+CHE+TUR+GBR+USA/all/all' }, education: { id: 'EAG_ENRL_SHARE_TET', label: 'Education at a Glance', endpoint: null }, employment: { id: 'LFS_SEXAGE_I_R', label: 'Labour Force Statistics', endpoint: null }, environment: { id: 'AIR_GHG_SECTOR', label: 'Air Greenhouse Gas Emissions', endpoint: null } }; router.get('/oecd/:dataset', async (req, res) => { try { const { dataset } = req.params; const country = (req.query.country || 'SWE').toUpperCase(); const from = req.query.from || '2015'; const to = req.query.to || '2023'; // OECD data.oecd.org REST API const datasets = { health: `https://sdmx.oecd.org/public/rest/data/OECD.ELS.HD,DSD_HEALTH_STAT@DF_HS,1.0/${country}......?startPeriod=${from}&endPeriod=${to}&format=jsondata&dimensionAtObservation=TIME_PERIOD`, education: `https://sdmx.oecd.org/public/rest/data/OECD.EDU.IMEP,DSD_EAG@DF_EAG_ENRL_SHARE_TET,1.0/${country}......?startPeriod=${from}&endPeriod=${to}&format=jsondata`, employment: `https://sdmx.oecd.org/public/rest/data/OECD.ELS.SAE,DSD_LFS@DF_LFS_INDIC,1.0/${country}.UNE_RATE.M.Y25T54......?startPeriod=${from}&endPeriod=${to}&format=jsondata`, environment: `https://sdmx.oecd.org/public/rest/data/OECD.ENV.EPI,DSD_AIR_GHG@DF_AIR_GHG_SECTOR,1.0/${country}......?startPeriod=${from}&endPeriod=${to}&format=jsondata` }; const url = datasets[dataset]; if (!url) return res.status(404).json({ error: 'unknown dataset', available: Object.keys(datasets) }); const data = await cached(`oecd:${dataset}:${country}:${from}:${to}`, TTL_24H, () => fetchJSON(url)); // Parse SDMX-JSON structure let series = []; if (data?.data?.dataSets?.[0]?.series) { const ds = data.data.dataSets[0]; const struct = data.data.structure; for (const [key, vals] of Object.entries(ds.series)) { const obs = Object.entries(vals.observations || {}).map(([t, v]) => ({ period: struct?.dimensions?.observation?.[0]?.values?.[parseInt(t)]?.id || t, value: v[0] })); series.push({ key, observations: obs }); } } res.json({ ok: true, dataset, country, from, to, series: series.slice(0, 50), raw_available: !!data?.data, source: 'OECD SDMX API', evidence_grade: 'E2', ts: new Date().toISOString() }); } catch (e) { res.status(500).json({ error: e.message }); } }); // ═══════════════════════════════════════════════════════════════════════════════ // D-013: UN Statistics API — SDG-indikatorer, demografi // ═══════════════════════════════════════════════════════════════════════════════ const UN_GOALS = { 1: 'No Poverty', 2: 'Zero Hunger', 3: 'Good Health', 4: 'Quality Education', 5: 'Gender Equality', 6: 'Clean Water', 7: 'Clean Energy', 8: 'Decent Work', 9: 'Industry/Innovation', 10: 'Reduced Inequalities', 11: 'Sustainable Cities', 12: 'Responsible Consumption', 13: 'Climate Action', 14: 'Life Below Water', 15: 'Life on Land', 16: 'Peace and Justice', 17: 'Partnerships' }; router.get('/un/sdg', async (req, res) => { try { const country = (req.query.country || 'SWE').toUpperCase(); const goal = req.query.goal || '3'; const from = req.query.from || '2015'; const to = req.query.to || '2023'; const url = `https://unstats.un.org/SDGAPI/v1/sdg/Indicator/Data?goal=${goal}&areaCode=${country}&timePeriodStart=${from}&timePeriodEnd=${to}&pageSize=100`; const data = await cached(`un:sdg:${goal}:${country}:${from}:${to}`, TTL_24H, () => fetchJSON(url)); const indicators = (data?.data || []).map(d => ({ goal, indicator: d.indicator, series: d.seriesCode, series_desc: d.seriesDescription, year: d.timePeriod?.timeDetail, value: d.value, unit: d.units })); res.json({ ok: true, country, goal: parseInt(goal), goal_name: UN_GOALS[parseInt(goal)], indicators: indicators.slice(0, 100), count: indicators.length, all_goals: UN_GOALS, source: 'UN Statistics Division', evidence_grade: 'E2', ts: new Date().toISOString() }); } catch (e) { res.status(500).json({ error: e.message }); } }); router.get('/un/demographics', async (req, res) => { try { const country = (req.query.country || 'SWE').toUpperCase(); const [pop, births, deaths] = await Promise.all([ wb(`country/${country}/indicator/SP.POP.TOTL?mrv=10`), wb(`country/${country}/indicator/SP.DYN.CBRT.IN?mrv=10`), wb(`country/${country}/indicator/SP.DYN.CDRT.IN?mrv=10`) ]); const popSeries = extractWBseries(pop).sort((a, b) => a.year - b.year); const birthSeries = extractWBseries(births).sort((a, b) => a.year - b.year); const deathSeries = extractWBseries(deaths).sort((a, b) => a.year - b.year); res.json({ ok: true, country, population: popSeries, birth_rate: birthSeries, death_rate: deathSeries, source: 'World Bank (UN data)', evidence_grade: 'E2', ts: new Date().toISOString() }); } catch (e) { res.status(500).json({ error: e.message }); } }); // ═══════════════════════════════════════════════════════════════════════════════ // D-014: Freedom House Index — demokratipoäng per land // ═══════════════════════════════════════════════════════════════════════════════ // Freedom House proxy — uses available WorldBank developmental/openness indicators // (WB WGI indicators GE.EST etc. were archived; using correlated proxy indicators) const FH_PROXY = [ { ind: 'NY.GDP.PCAP.CD', label: 'Economic Development', max: 70000, type: 'scale' }, { ind: 'IT.NET.USER.ZS', label: 'Information Access (Internet %)', max: 100, type: 'pct' }, { ind: 'SE.ADT.LITR.ZS', label: 'Education & Literacy', max: 100, type: 'pct' }, { ind: 'SE.XPD.TOTL.GD.ZS', label: 'Education Investment (% GDP)', max: 10, type: 'scale' }, { ind: 'SH.XPD.CHEX.GD.ZS', label: 'Health System (% GDP)', max: 20, type: 'scale' }, { ind: 'SP.DYN.LE00.IN', label: 'Life Quality (Life Expectancy)', max: 87, type: 'scale' } ]; router.get('/freedom', async (req, res) => { try { const country = (req.query.country || 'SWE').toUpperCase(); const year = req.query.year || '2022'; const all = req.query.all === 'true'; const countryPath = all ? 'all' : country; const results = await Promise.all( FH_PROXY.map(async ({ ind, label, max, type }) => { const resp = await wb(`country/${countryPath}/indicator/${ind}?mrv=1`); if (all) { const rawMap = extractWB(resp); const scoreMap = {}; for (const [iso, v] of Object.entries(rawMap)) { if (iso.length === 3 && v != null) scoreMap[iso] = Math.min(100, Math.round((v / max) * 100)); } return { indicator: ind, label, scores: scoreMap }; } else { const val = Array.isArray(resp) && resp[1]?.[0]?.value; const score = val != null ? Math.min(100, Math.round((val / max) * 100)) : null; return { indicator: ind, label, raw: val, score, year: resp[1]?.[0]?.date }; } }) ); if (all) { const countryScores = {}; const allISOs = new Set(results.flatMap(r => Object.keys(r.scores))); for (const iso of allISOs) { const scores = results.map(r => r.scores[iso]).filter(x => x != null); if (scores.length >= 3) countryScores[iso] = Math.round(scores.reduce((a, b) => a + b) / scores.length); } return res.json({ ok: true, type: 'global', democracy_scores: countryScores, indicators: results.map(r => ({ id: r.indicator, label: r.label })), note: 'Composite developmental index as democracy proxy', source: 'World Bank Open Data (composite proxy)', evidence_grade: 'E3', ts: new Date().toISOString() }); } const valid = results.filter(r => r.score != null); const overall_democracy = valid.length ? Math.round(valid.reduce((s, r) => s + r.score, 0) / valid.length) : null; res.json({ ok: true, country, year: valid[0]?.year || year, overall_democracy, indicators: results, classification: overall_democracy >= 80 ? 'Free' : overall_democracy >= 55 ? 'Partly Free' : 'Not Free', note: 'Composite developmental/openness index as Freedom House proxy (WGI archived by WorldBank)', source: 'World Bank Open Data (composite proxy)', evidence_grade: 'E3', ts: new Date().toISOString() }); } catch (e) { res.status(500).json({ error: e.message }); } }); // ═══════════════════════════════════════════════════════════════════════════════ // D-017: Budget Comparison — statsbudgetar, IMF + SCB // ═══════════════════════════════════════════════════════════════════════════════ router.get('/budget/:country', async (req, res) => { try { const country = req.params.country.toUpperCase(); const mrv = 'mrv=5'; const [revenue, expenditure, deficit, debt, gdp] = await Promise.all([ wb(`country/${country}/indicator/GC.REV.TOTL.GD.ZS?${mrv}`), wb(`country/${country}/indicator/GC.XPN.TOTL.GD.ZS?${mrv}`), wb(`country/${country}/indicator/GC.NLD.TOTL.GD.ZS?${mrv}`), wb(`country/${country}/indicator/GC.DOD.TOTL.GD.ZS?${mrv}`), wb(`country/${country}/indicator/NY.GDP.MKTP.CD?${mrv}`) ]); const fmt = (resp) => extractWBseries(resp).sort((a, b) => b.year - a.year).slice(0, 5); res.json({ ok: true, country, budget: { revenue_pct_gdp: fmt(revenue), expenditure_pct_gdp: fmt(expenditure), net_lending_pct_gdp: fmt(deficit), debt_pct_gdp: fmt(debt), gdp_usd: fmt(gdp) }, source: 'World Bank / IMF Government Finance Statistics', evidence_grade: 'E3', ts: new Date().toISOString() }); } catch (e) { res.status(500).json({ error: e.message }); } }); router.get('/budget/compare', async (req, res) => { try { const countries = (req.query.countries || 'SWE,NOR,DNK,FIN,DEU').split(',').slice(0, 8).map(c => c.trim().toUpperCase()); const ind = req.query.indicator || 'GC.DOD.TOTL.GD.ZS'; const year = req.query.year || '2022'; const results = await Promise.all(countries.map(async c => { const resp = await wb(`country/${c}/indicator/${ind}?date=${year}`); const val = Array.isArray(resp) && resp[1]?.[0]?.value; return { country: c, value: val, year }; })); results.sort((a, b) => (b.value || 0) - (a.value || 0)); res.json({ ok: true, indicator: ind, year, comparison: results, source: 'World Bank', evidence_grade: 'E3', ts: new Date().toISOString() }); } catch (e) { res.status(500).json({ error: e.message }); } }); // ═══════════════════════════════════════════════════════════════════════════════ // D-018: Education Deep Dive — Skolverket + PISA (WorldBank proxy) // ═══════════════════════════════════════════════════════════════════════════════ router.get('/education/:country', async (req, res) => { try { const country = req.params.country.toUpperCase(); const mrv = 'mrv=5'; const [enrollment_primary, enrollment_secondary, enrollment_tertiary, expenditure, literacy, teachers, pisa_proxy] = await Promise.all([ wb(`country/${country}/indicator/SE.PRM.ENRR?${mrv}`), wb(`country/${country}/indicator/SE.SEC.ENRR?${mrv}`), wb(`country/${country}/indicator/SE.TER.ENRR?${mrv}`), wb(`country/${country}/indicator/SE.XPD.TOTL.GD.ZS?${mrv}`), wb(`country/${country}/indicator/SE.ADT.LITR.ZS?${mrv}`), wb(`country/${country}/indicator/SE.PRM.TCHR.FE.ZS?${mrv}`), wb(`country/${country}/indicator/SE.XPD.PRIM.PC.ZS?${mrv}`) // prim ed spending per pupil ]); const fmt = (resp) => extractWBseries(resp).sort((a, b) => b.year - a.year).slice(0, 5); // Kolada PISA/Skola för svenska kommuner let kolada_data = null; if (country === 'SWE') { const koladaResp = await kolada('data/kpi/N15419/year/2022'); kolada_data = { source: 'Kolada/Skolverket', kpi: 'N15419', label: 'Behörighet gymnasium (%)', data: koladaResp }; } res.json({ ok: true, country, education: { enrollment_primary: fmt(enrollment_primary), enrollment_secondary: fmt(enrollment_secondary), enrollment_tertiary: fmt(enrollment_tertiary), expenditure_pct_gdp: fmt(expenditure), adult_literacy: fmt(literacy), female_teachers_primary: fmt(teachers), spending_per_pupil: fmt(pisa_proxy) }, sweden_specific: kolada_data, source: 'World Bank + Kolada (SWE)', evidence_grade: 'E3', ts: new Date().toISOString() }); } catch (e) { res.status(500).json({ error: e.message }); } }); // ═══════════════════════════════════════════════════════════════════════════════ // D-006: CSV Export Dataset — land + indikator + år // ═══════════════════════════════════════════════════════════════════════════════ router.get('/export/csv', async (req, res) => { try { const country = (req.query.country || 'SWE').toUpperCase(); const indicator = req.query.indicator || 'NY.GDP.PCAP.CD'; const from = req.query.from || '2000'; const to = req.query.to || '2023'; const resp = await wb(`country/${country}/indicator/${indicator}?date=${from}:${to}&per_page=200`); const series = extractWBseries(resp).sort((a, b) => a.year - b.year); const label = IND_LABELS[indicator] || indicator; let csv = `country,indicator,label,year,value\n`; series.forEach(p => { csv += `${country},${indicator},"${label}",${p.year},${p.value}\n`; }); res.setHeader('Content-Type', 'text/csv'); res.setHeader('Content-Disposition', `attachment; filename="dissg_${country}_${indicator}_${from}-${to}.csv"`); res.send(csv); auditLog('export.csv', { country, indicator }); } catch (e) { res.status(500).json({ error: e.message }); } }); router.post('/export/csv', async (req, res) => { try { const { countries = ['SWE', 'NOR', 'DNK'], indicators = ['NY.GDP.PCAP.CD', 'SL.UEM.TOTL.ZS'], from = '2015', to = '2023' } = req.body; let csv = `country,indicator,label,year,value\n`; for (const country of countries.slice(0, 10)) { for (const indicator of indicators.slice(0, 5)) { const resp = await wb(`country/${country}/indicator/${indicator}?date=${from}:${to}&per_page=100`); const series = extractWBseries(resp).sort((a, b) => a.year - b.year); const label = IND_LABELS[indicator] || indicator; series.forEach(p => { csv += `${country},${indicator},"${label}",${p.year},${p.value}\n`; }); } } res.setHeader('Content-Type', 'text/csv'); res.setHeader('Content-Disposition', `attachment; filename="dissg_export_${Date.now()}.csv"`); res.send(csv); auditLog('export.csv.multi', { countries, indicators }); } catch (e) { res.status(500).json({ error: e.message }); } }); // ═══════════════════════════════════════════════════════════════════════════════ // D-005: PDF Export DISSG-rapport — nyckeltal + grafer + källor // ═══════════════════════════════════════════════════════════════════════════════ router.get('/export/pdf', async (req, res) => { try { const country = (req.query.country || 'SWE').toUpperCase(); const year = req.query.year || '2023'; // Fetch key indicators const [gdp, life, unemp, internet] = await Promise.all([ wb(`country/${country}/indicator/NY.GDP.PCAP.CD?date=${year}`), wb(`country/${country}/indicator/SP.DYN.LE00.IN?date=${year}`), wb(`country/${country}/indicator/SL.UEM.TOTL.ZS?date=${year}`), wb(`country/${country}/indicator/IT.NET.USER.ZS?date=${year}`) ]); const get1 = (resp) => Array.isArray(resp) && resp[1]?.[0]?.value; const gdpVal = get1(gdp), lifeVal = get1(life), unempVal = get1(unemp), netVal = get1(internet); const html = `
Country: ${country} Year: ${year} E3 Evidence
Evidence Grade: E3 (peer-reviewed public data)
App ${client_id || 'unknown'} requests access to DISSG data.
Scope: ${scope || 'read'}