bae705aa97
- Add NFC ePassport roadmap (ICAO 9303, eIDAS) - Add TensorFlow.js edge face detection (BlazeFace) - Add structured audit logger (GDPR-compliant) - Risk scoring support Part of KYC Apple Native UX v1.1.0
1399 lines
76 KiB
JavaScript
1399 lines
76 KiB
JavaScript
/**
|
|
* 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 = `<!DOCTYPE html>
|
|
<html><head><meta charset="utf-8"><title>DISSG Report — ${country} ${year}</title>
|
|
<style>
|
|
body { font-family: Arial, sans-serif; margin: 40px; color: #1a1a2e; }
|
|
h1 { color: #0f3460; border-bottom: 3px solid #e94560; padding-bottom: 10px; }
|
|
h2 { color: #16213e; }
|
|
.kpi-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; margin: 20px 0; }
|
|
.kpi-card { background: #f8f9fa; border-left: 4px solid #e94560; padding: 15px; border-radius: 4px; }
|
|
.kpi-value { font-size: 2em; font-weight: bold; color: #0f3460; }
|
|
.kpi-label { color: #666; font-size: 0.9em; }
|
|
.source { font-size: 0.8em; color: #888; margin-top: 40px; border-top: 1px solid #eee; padding-top: 10px; }
|
|
.badge { display: inline-block; background: #e94560; color: white; padding: 3px 8px; border-radius: 12px; font-size: 0.8em; }
|
|
@media print { body { margin: 20px; } }
|
|
</style></head>
|
|
<body>
|
|
<h1>🌍 DISSG Country Report</h1>
|
|
<p><strong>Country:</strong> ${country} <strong>Year:</strong> ${year} <span class="badge">E3 Evidence</span></p>
|
|
<h2>Key Indicators</h2>
|
|
<div class="kpi-grid">
|
|
<div class="kpi-card"><div class="kpi-value">$${gdpVal ? Math.round(gdpVal).toLocaleString() : 'N/A'}</div><div class="kpi-label">GDP per Capita (USD)</div></div>
|
|
<div class="kpi-card"><div class="kpi-value">${lifeVal ? lifeVal.toFixed(1) : 'N/A'}</div><div class="kpi-label">Life Expectancy (years)</div></div>
|
|
<div class="kpi-card"><div class="kpi-value">${unempVal ? unempVal.toFixed(1) + '%' : 'N/A'}</div><div class="kpi-label">Unemployment Rate</div></div>
|
|
<div class="kpi-card"><div class="kpi-value">${netVal ? Math.round(netVal) + '%' : 'N/A'}</div><div class="kpi-label">Internet Users</div></div>
|
|
</div>
|
|
<h2>Data Sources</h2>
|
|
<ul>
|
|
<li>World Bank Open Data API (api.worldbank.org/v2)</li>
|
|
<li>Kolada — Kommunal statistik (api.kolada.se/v3)</li>
|
|
<li>Riksdagen Open Data (data.riksdagen.se)</li>
|
|
<li>UN Statistics Division (unstats.un.org)</li>
|
|
<li>OECD SDMX API (sdmx.oecd.org)</li>
|
|
</ul>
|
|
<p>Evidence Grade: <strong>E3</strong> (peer-reviewed public data)</p>
|
|
<div class="source">Generated by DISSG/AAMOS Platform — ${new Date().toISOString()} — dissg.wavult.com</div>
|
|
</body></html>`;
|
|
|
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
res.setHeader('Content-Disposition', `attachment; filename="DISSG_Report_${country}_${year}.html"`);
|
|
res.send(html);
|
|
auditLog('export.pdf', { country, year });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// D-007: DISSG PRO Subscription + Rate Limiting
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
const TIERS = {
|
|
free: { price: 0, calls_per_min: 10, calls_per_day: 100, features: ['gdm', 'trends', 'csv'] },
|
|
pro: { price: 799, calls_per_min: 60, calls_per_day: 5000, features: ['gdm', 'trends', 'csv', 'pdf', 'causal', 'gmi', 'oecd', 'un', 'alerts'] },
|
|
enterprise: { price: null, calls_per_min: 600, calls_per_day: null, features: ['all'] }
|
|
};
|
|
|
|
router.get('/subscription/tiers', (_, res) => {
|
|
res.json({ ok: true, tiers: TIERS, currency: 'SEK', ts: new Date().toISOString() });
|
|
});
|
|
|
|
router.post('/subscription/subscribe', async (req, res) => {
|
|
try {
|
|
const { user_id, tier, email } = req.body;
|
|
if (!user_id || !tier) return res.status(400).json({ error: 'user_id and tier required' });
|
|
if (!TIERS[tier]) return res.status(400).json({ error: 'invalid tier', valid: Object.keys(TIERS) });
|
|
|
|
const pool = getPool();
|
|
await pool.query(`
|
|
INSERT INTO dissg_subscriptions (user_id, tier, email, created_at, active)
|
|
VALUES ($1, $2, $3, NOW(), true)
|
|
ON CONFLICT (user_id) DO UPDATE SET tier=$2, email=$3, updated_at=NOW(), active=true
|
|
`, [user_id, tier, email || null]);
|
|
|
|
const sub_id = `dissg_${tier}_${createHash('sha256').update(user_id + tier).digest('hex').slice(0, 12)}`;
|
|
res.json({ ok: true, sub_id, tier, limits: TIERS[tier], ts: new Date().toISOString() });
|
|
auditLog('subscription.create', { user_id, tier });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
router.get('/subscription/status', async (req, res) => {
|
|
try {
|
|
const user_id = req.query.user_id || req.user?.id;
|
|
if (!user_id) return res.status(400).json({ error: 'user_id required' });
|
|
|
|
const pool = getPool();
|
|
const r = await pool.query('SELECT tier, active, created_at FROM dissg_subscriptions WHERE user_id=$1', [user_id]);
|
|
const sub = r.rows[0] || { tier: 'free', active: true };
|
|
|
|
res.json({ ok: true, user_id, tier: sub.tier, active: sub.active, limits: TIERS[sub.tier], ts: new Date().toISOString() });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// D-008: DISSG Login/Register — JWT via AMOS Identity
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
router.post('/auth/register', async (req, res) => {
|
|
try {
|
|
const { email, password, name } = req.body;
|
|
if (!email || !password) return res.status(400).json({ error: 'email and password required' });
|
|
|
|
// Forward to AMOS identity service
|
|
const result = await new Promise(resolve => {
|
|
const body = JSON.stringify({ email, password, name: name || email.split('@')[0], product: 'dissg' });
|
|
const options = {
|
|
hostname: 'localhost', port: 3100, path: '/api/auth/register',
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
|
|
};
|
|
const req2 = http.request(options, r2 => {
|
|
let d = ''; r2.on('data', c => d += c);
|
|
r2.on('end', () => { try { resolve(JSON.parse(d)); } catch { resolve({ error: 'parse' }); } });
|
|
});
|
|
req2.on('error', e => resolve({ error: e.message }));
|
|
req2.write(body); req2.end();
|
|
});
|
|
|
|
if (result.error) return res.status(400).json({ error: result.error });
|
|
auditLog('auth.register', { email });
|
|
res.json({ ok: true, message: 'registered', ...result });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
router.post('/auth/login', async (req, res) => {
|
|
try {
|
|
const { email, password } = req.body;
|
|
if (!email || !password) return res.status(400).json({ error: 'email and password required' });
|
|
|
|
const result = await new Promise(resolve => {
|
|
const body = JSON.stringify({ email, password });
|
|
const options = {
|
|
hostname: 'localhost', port: 3100, path: '/api/auth/login',
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
|
|
};
|
|
const req2 = http.request(options, r2 => {
|
|
let d = ''; r2.on('data', c => d += c);
|
|
r2.on('end', () => { try { resolve(JSON.parse(d)); } catch { resolve({ error: 'parse' }); } });
|
|
});
|
|
req2.on('error', e => resolve({ error: e.message }));
|
|
req2.write(body); req2.end();
|
|
});
|
|
|
|
auditLog('auth.login', { email });
|
|
res.json(result);
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// D-015: Alert System — notifiera ny statistik
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
const _alertSubs = new Map(); // in-memory for now
|
|
const _alerts = [];
|
|
|
|
router.post('/alerts/subscribe', async (req, res) => {
|
|
try {
|
|
const { user_id, country, indicator, threshold, direction } = req.body;
|
|
if (!user_id || !indicator) return res.status(400).json({ error: 'user_id and indicator required' });
|
|
|
|
const id = `alert_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
const sub = { id, user_id, country: country || 'SWE', indicator, threshold, direction: direction || 'any', created_at: new Date().toISOString(), active: true };
|
|
|
|
const pool = getPool();
|
|
await pool.query(`
|
|
INSERT INTO dissg_alerts (id, user_id, country, indicator, threshold, direction, created_at, active)
|
|
VALUES ($1,$2,$3,$4,$5,$6,NOW(),true)
|
|
`, [id, user_id, sub.country, indicator, threshold || null, sub.direction]).catch(() => {});
|
|
|
|
_alertSubs.set(id, sub);
|
|
res.json({ ok: true, alert_id: id, subscription: sub });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
router.get('/alerts', async (req, res) => {
|
|
try {
|
|
const user_id = req.query.user_id;
|
|
let alerts = [];
|
|
try {
|
|
const pool = getPool();
|
|
const r = await pool.query('SELECT * FROM dissg_alerts WHERE user_id=$1 OR $1 IS NULL ORDER BY created_at DESC LIMIT 50', [user_id || null]);
|
|
alerts = r.rows;
|
|
} catch { alerts = [..._alertSubs.values()]; }
|
|
|
|
res.json({ ok: true, alerts, count: alerts.length, ts: new Date().toISOString() });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
router.post('/alerts/check', async (req, res) => {
|
|
try {
|
|
// Check WorldBank data against thresholds
|
|
const triggered = [];
|
|
for (const [id, sub] of _alertSubs) {
|
|
if (!sub.active) continue;
|
|
const resp = await wb(`country/${sub.country}/indicator/${sub.indicator}?mrv=1`);
|
|
const val = Array.isArray(resp) && resp[1]?.[0]?.value;
|
|
if (val != null && sub.threshold != null) {
|
|
const hit = (sub.direction === 'above' && val > sub.threshold) ||
|
|
(sub.direction === 'below' && val < sub.threshold) ||
|
|
(sub.direction === 'any');
|
|
if (hit) triggered.push({ ...sub, current_value: val });
|
|
}
|
|
}
|
|
_alerts.push(...triggered);
|
|
res.json({ ok: true, checked: _alertSubs.size, triggered: triggered.length, alerts: triggered, ts: new Date().toISOString() });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// D-016: DISSG Systemlogg API — audit log queries
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
router.get('/log', async (req, res) => {
|
|
try {
|
|
const { limit = 50, event, from, to } = req.query;
|
|
const pool = getPool();
|
|
let q = 'SELECT * FROM dissg_audit_log WHERE 1=1';
|
|
const params = [];
|
|
if (event) { q += ` AND event = $${params.length + 1}`; params.push(event); }
|
|
if (from) { q += ` AND ts >= $${params.length + 1}`; params.push(from); }
|
|
if (to) { q += ` AND ts <= $${params.length + 1}`; params.push(to); }
|
|
q += ` ORDER BY ts DESC LIMIT $${params.length + 1}`;
|
|
params.push(Math.min(parseInt(limit), 500));
|
|
|
|
const r = await pool.query(q, params);
|
|
res.json({ ok: true, count: r.rows.length, logs: r.rows, ts: new Date().toISOString() });
|
|
} catch (e) {
|
|
// Fallback if table doesn't exist
|
|
res.json({ ok: true, count: 0, logs: [], note: 'DB table pending', ts: new Date().toISOString() });
|
|
}
|
|
});
|
|
|
|
router.get('/log/stats', async (req, res) => {
|
|
try {
|
|
const pool = getPool();
|
|
const r = await pool.query(`
|
|
SELECT event, COUNT(*) as count, MAX(ts) as last_seen
|
|
FROM dissg_audit_log GROUP BY event ORDER BY count DESC
|
|
`);
|
|
res.json({ ok: true, stats: r.rows, ts: new Date().toISOString() });
|
|
} catch (e) {
|
|
res.json({ ok: true, stats: [], note: 'DB table pending', ts: new Date().toISOString() });
|
|
}
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// D-019: DISSG API Gateway OAuth2 — rate limiting per tier
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
router.get('/oauth2/authorize', (req, res) => {
|
|
const { client_id, redirect_uri, scope, state, response_type } = req.query;
|
|
const html = `<!DOCTYPE html><html><head><title>DISSG OAuth2</title>
|
|
<style>body{font-family:system-ui;max-width:400px;margin:100px auto;padding:20px;text-align:center}
|
|
.btn{background:#e94560;color:white;border:none;padding:12px 30px;border-radius:6px;cursor:pointer;font-size:16px}</style>
|
|
</head><body>
|
|
<h2>🔐 DISSG Authorization</h2>
|
|
<p>App <strong>${client_id || 'unknown'}</strong> requests access to DISSG data.</p>
|
|
<p>Scope: <code>${scope || 'read'}</code></p>
|
|
<button class="btn" onclick="authorize()">Authorize</button>
|
|
<script>function authorize(){const code='dc_'+Math.random().toString(36).slice(2,14);
|
|
window.location='${redirect_uri || '/'}?code='+code+'&state=${state || ''}';}</script>
|
|
</body></html>`;
|
|
res.send(html);
|
|
});
|
|
|
|
router.post('/oauth2/token', (req, res) => {
|
|
const { grant_type, code, client_id, client_secret } = req.body;
|
|
if (grant_type !== 'authorization_code' || !code) {
|
|
return res.status(400).json({ error: 'invalid_grant' });
|
|
}
|
|
const access_token = 'dsat_' + createHash('sha256').update(code + client_id + Date.now()).digest('hex').slice(0, 32);
|
|
res.json({ access_token, token_type: 'Bearer', expires_in: 3600, scope: 'read',
|
|
tier: 'pro', ts: new Date().toISOString() });
|
|
});
|
|
|
|
router.get('/oauth2/ratelimit', (req, res) => {
|
|
const ip = req.ip || '127.0.0.1';
|
|
const tier = req.query.tier || 'free';
|
|
const allowed = checkRL(ip, tier);
|
|
res.json({ ok: allowed, tier, ip_masked: ip.slice(0, -4) + '****',
|
|
limits: TIERS[tier] || TIERS.free, ts: new Date().toISOString() });
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// D-011: Debattläge PWA — mobil, 8-sek svar, offline
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
router.get('/debate', (_, res) => {
|
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
res.send(`<!DOCTYPE html>
|
|
<html lang="sv"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>DISSG Debattläge</title>
|
|
<link rel="manifest" href="/api/dissg/debate/manifest.json">
|
|
<style>
|
|
:root{--bg:#0f3460;--accent:#e94560;--card:#16213e;--text:#eee}
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
body{background:var(--bg);color:var(--text);font-family:system-ui;min-height:100vh}
|
|
.header{background:var(--card);padding:16px;display:flex;align-items:center;gap:12px;position:sticky;top:0;z-index:10}
|
|
.logo{font-size:1.5em;font-weight:900;color:var(--accent)}
|
|
.search{background:#1a2a4a;border:1px solid #333;color:#eee;padding:12px 16px;border-radius:8px;width:100%;font-size:16px}
|
|
.main{padding:16px;max-width:800px;margin:0 auto}
|
|
.question-bar{display:flex;gap:8px;margin-bottom:16px}
|
|
.ask-btn{background:var(--accent);color:white;border:none;padding:12px 20px;border-radius:8px;cursor:pointer;font-weight:bold;white-space:nowrap}
|
|
.card{background:var(--card);border-radius:12px;padding:16px;margin-bottom:12px;border-left:3px solid var(--accent)}
|
|
.kpi{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;margin:12px 0}
|
|
.kpi-item{background:#1a2a4a;padding:12px;border-radius:8px;text-align:center}
|
|
.kpi-val{font-size:1.6em;font-weight:900;color:var(--accent)}
|
|
.kpi-label{font-size:.75em;color:#aaa;margin-top:2px}
|
|
.loading{text-align:center;padding:20px;color:var(--accent)}
|
|
.offline-banner{background:#e94560;padding:8px;text-align:center;display:none}
|
|
.chips{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:12px}
|
|
.chip{background:#1a2a4a;border:1px solid #333;color:#ccc;padding:6px 12px;border-radius:20px;cursor:pointer;font-size:.85em}
|
|
.chip:hover{border-color:var(--accent);color:var(--accent)}
|
|
</style></head>
|
|
<body>
|
|
<div class="offline-banner" id="offline">📵 Offline — visar cachad data</div>
|
|
<div class="header"><span class="logo">DISSG</span><span style="color:#aaa;font-size:.9em">Debattläge</span></div>
|
|
<div class="main">
|
|
<div class="question-bar">
|
|
<input class="search" id="q" placeholder="Skriv en fråga om statistik... t.ex. 'BNP per capita Sverige'" type="text" onkeydown="if(event.key==='Enter')ask()">
|
|
<button class="ask-btn" onclick="ask()">Fråga</button>
|
|
</div>
|
|
<div class="chips">
|
|
<span class="chip" onclick="setQ('BNP per capita Sverige 2023')">🇸🇪 BNP Sverige</span>
|
|
<span class="chip" onclick="setQ('Livslängd Norden')">❤️ Livslängd Norden</span>
|
|
<span class="chip" onclick="setQ('Arbetslöshet Europa')">💼 Arbetslöshet</span>
|
|
<span class="chip" onclick="setQ('Demokratiindex länder')">🗳️ Demokrati</span>
|
|
<span class="chip" onclick="setQ('Riksdagsbeslut senaste')">🏛️ Riksdagsbeslut</span>
|
|
<span class="chip" onclick="setQ('CO2 utsläpp Sverige')">🌱 CO2</span>
|
|
</div>
|
|
<div id="result"></div>
|
|
</div>
|
|
<script>
|
|
const API = '/api/dissg';
|
|
const TOKEN = localStorage.getItem('dissg_token') || '';
|
|
const CACHE_KEY = 'dissg_cache';
|
|
let offline = false;
|
|
|
|
window.addEventListener('offline', () => { offline=true; document.getElementById('offline').style.display='block'; });
|
|
window.addEventListener('online', () => { offline=false; document.getElementById('offline').style.display='none'; });
|
|
|
|
function setQ(v){ document.getElementById('q').value=v; ask(); }
|
|
|
|
async function fetchWithTimeout(url, opts={}, ms=8000) {
|
|
const ctrl = new AbortController();
|
|
const tid = setTimeout(() => ctrl.abort(), ms);
|
|
try {
|
|
const r = await fetch(url, {...opts, signal: ctrl.signal, headers:{'Authorization':'Bearer '+TOKEN,...(opts.headers||{})}});
|
|
clearTimeout(tid);
|
|
return r.json();
|
|
} catch(e) {
|
|
clearTimeout(tid);
|
|
// Try cache
|
|
const cache = JSON.parse(localStorage.getItem(CACHE_KEY)||'{}');
|
|
if(cache[url]) return cache[url];
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async function ask() {
|
|
const q = document.getElementById('q').value.trim();
|
|
if(!q) return;
|
|
const el = document.getElementById('result');
|
|
el.innerHTML = '<div class="loading">⏳ Hämtar data...</div>';
|
|
|
|
try {
|
|
const start = Date.now();
|
|
let html = '';
|
|
|
|
// Detect query type
|
|
if(/bnp|gdp|ekonomi/i.test(q)) {
|
|
const country = /norge|norsk/i.test(q)?'NOR':/finnland|finland/i.test(q)?'FIN':/danmark|dansk/i.test(q)?'DNK':'SWE';
|
|
const data = await fetchWithTimeout(API+'/trends/gdp_per_capita?country='+country+'&from=2018&to=2023');
|
|
const latest = data.series?.slice(-1)[0];
|
|
html = '<div class="card"><h3>💰 BNP per capita — '+country+'</h3><div class="kpi"><div class="kpi-item"><div class="kpi-val">$'+Math.round(latest?.value||0).toLocaleString()+'</div><div class="kpi-label">'+latest?.year+'</div></div><div class="kpi-item"><div class="kpi-val">'+(data.trend?.direction==='up'?'📈':'📉')+'</div><div class="kpi-label">Trend: '+data.trend?.direction+'</div></div></div></div>';
|
|
} else if(/demokrati|frihet|freedom/i.test(q)) {
|
|
const data = await fetchWithTimeout(API+'/freedom?country=SWE');
|
|
html = '<div class="card"><h3>🗳️ Demokratiindex</h3><div class="kpi">'+data.indicators?.slice(0,4).map(i=>'<div class="kpi-item"><div class="kpi-val">'+i.score+'</div><div class="kpi-label">'+i.label.split(' ')[0]+'</div></div>').join('')+'</div></div>';
|
|
} else if(/riksdag|beslut/i.test(q)) {
|
|
const data = await fetchWithTimeout(API+'/decisions?sz=5');
|
|
html = '<div class="card"><h3>🏛️ Senaste Riksdagsbeslut</h3>'+data.decisions?.slice(0,3).map(d=>'<div style="margin:8px 0;padding:8px;background:#1a2a4a;border-radius:6px"><div style="font-weight:bold;font-size:.9em">'+d.title+'</div><div style="color:#aaa;font-size:.75em">'+d.date+' · '+d.organ+'</div></div>').join('')+'</div>';
|
|
} else if(/livslängd|life/i.test(q)) {
|
|
const countries = ['SWE','NOR','DNK','FIN'];
|
|
const fetches = await Promise.all(countries.map(c=>fetchWithTimeout(API+'/trends/life_expectancy?country='+c+'&from=2020&to=2023')));
|
|
html = '<div class="card"><h3>❤️ Livslängd Norden</h3><div class="kpi">'+fetches.map((d,i)=>'<div class="kpi-item"><div class="kpi-val">'+d.series?.slice(-1)[0]?.value?.toFixed(1)+'</div><div class="kpi-label">'+countries[i]+'</div></div>').join('')+'</div></div>';
|
|
} else if(/co2|utsläpp|klimat/i.test(q)) {
|
|
const data = await fetchWithTimeout(API+'/trends/co2_per_capita?country=SWE&from=2010&to=2022');
|
|
const latest = data.series?.slice(-1)[0];
|
|
html = '<div class="card"><h3>🌱 CO2 per capita — SWE</h3><div class="kpi"><div class="kpi-item"><div class="kpi-val">'+latest?.value?.toFixed(2)+'t</div><div class="kpi-label">'+latest?.year+'</div></div><div class="kpi-item"><div class="kpi-val">'+(data.trend?.direction==='down'?'✅':'⚠️')+'</div><div class="kpi-label">'+data.trend?.direction+'</div></div></div></div>';
|
|
} else {
|
|
html = '<div class="card"><h3>🔍 Söker...</h3><p style="color:#aaa">Försöker tolka: <em>'+q+'</em></p><p style="margin-top:8px">Prova: BNP, Livslängd, Demokrati, Riksdagsbeslut, CO2</p></div>';
|
|
}
|
|
|
|
const elapsed = Date.now() - start;
|
|
el.innerHTML = html + '<div style="color:#555;font-size:.75em;text-align:right;margin-top:8px">⚡ '+elapsed+'ms · DISSG/World Bank</div>';
|
|
// Cache
|
|
const cache = JSON.parse(localStorage.getItem(CACHE_KEY)||'{}');
|
|
cache['last_result'] = html;
|
|
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
|
|
} catch(e) {
|
|
el.innerHTML = '<div class="card" style="border-color:#f39c12">⚠️ Fel: '+e.message+'</div>';
|
|
}
|
|
}
|
|
</script>
|
|
</body></html>`);
|
|
});
|
|
|
|
router.get('/debate/manifest.json', (_, res) => {
|
|
res.json({
|
|
name: 'DISSG Debattläge', short_name: 'DISSG',
|
|
description: 'Decision Intelligence — statistik för debatt',
|
|
start_url: '/api/dissg/debate', display: 'standalone',
|
|
background_color: '#0f3460', theme_color: '#e94560',
|
|
icons: [{ src: 'https://amos.wavult.com/favicon.ico', sizes: '192x192', type: 'image/png' }]
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// D-020: Signalöversikt Dashboard — animerad SVG, iOS
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
router.get('/dashboard', async (req, res) => {
|
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
res.send(`<!DOCTYPE html>
|
|
<html lang="sv"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
<title>DISSG Signalöversikt</title>
|
|
<style>
|
|
:root{--bg:#060b18;--card:#0d1b2a;--accent:#00d4ff;--warn:#ff6b35;--ok:#00ff88;--text:#e0f0ff}
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
body{background:var(--bg);color:var(--text);font-family:'SF Pro Display',system-ui;overflow-x:hidden;min-height:100vh;padding-bottom:env(safe-area-inset-bottom)}
|
|
.header{padding:20px 16px 10px;padding-top:calc(20px + env(safe-area-inset-top));background:linear-gradient(180deg,#0a0f1e 0%,transparent 100%);position:sticky;top:0;z-index:10}
|
|
.header-title{font-size:1.4em;font-weight:800;letter-spacing:-.5px;background:linear-gradient(135deg,var(--accent),#0080ff);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
|
.subtitle{font-size:.75em;color:#4a7090;margin-top:2px}
|
|
.grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:12px 12px;max-width:800px;margin:0 auto}
|
|
.card{background:var(--card);border-radius:16px;padding:16px;position:relative;overflow:hidden;border:1px solid #1a3a5c}
|
|
.card::before{content:'';position:absolute;top:0;left:0;right:0;height:2px}
|
|
.card.blue::before{background:linear-gradient(90deg,var(--accent),#0040ff)}
|
|
.card.green::before{background:linear-gradient(90deg,var(--ok),#00a860)}
|
|
.card.orange::before{background:linear-gradient(90deg,var(--warn),#ff4500)}
|
|
.card.purple::before{background:linear-gradient(90deg,#a060ff,#6030d0)}
|
|
.card-label{font-size:.7em;color:#4a7090;text-transform:uppercase;letter-spacing:.8px;font-weight:600}
|
|
.card-value{font-size:2em;font-weight:900;margin:4px 0;letter-spacing:-1px}
|
|
.card-sub{font-size:.75em;color:#4a7090}
|
|
.trend{font-size:.8em;margin-top:4px}
|
|
.trend.up{color:var(--ok)}
|
|
.trend.down{color:var(--warn)}
|
|
.trend.flat{color:#4a7090}
|
|
.sparkline{margin-top:8px}
|
|
.wide{grid-column:1/-1}
|
|
.map-preview{background:#0a1520;border-radius:12px;padding:12px;grid-column:1/-1;border:1px solid #1a3a5c}
|
|
.countries{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
|
|
.country-badge{background:#0d2040;padding:4px 10px;border-radius:20px;font-size:.75em;color:#7ab0d0}
|
|
.signal-list{margin-top:8px}
|
|
.signal-item{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid #1a3a5c}
|
|
.signal-item:last-child{border-bottom:none}
|
|
.signal-name{font-size:.85em;color:#8ab0d0}
|
|
.signal-score{font-size:.9em;font-weight:700}
|
|
.loading-pulse{animation:pulse 1.5s ease-in-out infinite}
|
|
@keyframes pulse{0%,100%{opacity:.4}50%{opacity:1}}
|
|
svg.spark path{fill:none;stroke-width:2}
|
|
</style></head>
|
|
<body>
|
|
<div class="header">
|
|
<div class="header-title">DISSG Signalöversikt</div>
|
|
<div class="subtitle" id="updated">Laddar...</div>
|
|
</div>
|
|
<div class="grid" id="grid">
|
|
<div class="card blue loading-pulse"><div class="card-label">BNP/capita</div><div class="card-value" id="gdp">—</div><div class="card-sub">Sverige · USD</div></div>
|
|
<div class="card green loading-pulse"><div class="card-label">Livslängd</div><div class="card-value" id="life">—</div><div class="card-sub">SWE · år</div></div>
|
|
<div class="card orange loading-pulse"><div class="card-label">Arbetslöshet</div><div class="card-value" id="unemp">—</div><div class="card-sub">Sverige · %</div></div>
|
|
<div class="card purple loading-pulse"><div class="card-label">Demokrati</div><div class="card-value" id="demo">—</div><div class="card-sub">Score 0-100</div></div>
|
|
<div class="card blue wide"><div class="card-label">Global Master Index — Norden</div><div class="signal-list" id="gmi-list"></div></div>
|
|
<div class="map-preview"><div class="card-label">Senaste Riksdagsbeslut</div><div id="decisions" class="signal-list loading-pulse">Laddar...</div></div>
|
|
</div>
|
|
<script>
|
|
const API = '/api/dissg';
|
|
const TOKEN = localStorage.getItem('dissg_token')||'';
|
|
const H = (t) => ({ headers: { 'Authorization': 'Bearer '+TOKEN } });
|
|
|
|
async function load() {
|
|
try {
|
|
const [gdpD, lifeD, unempD, demoD, gmiD, decisionsD] = await Promise.all([
|
|
fetch(API+'/trends/gdp_per_capita?country=SWE&from=2020&to=2023',H()).then(r=>r.json()).catch(()=>null),
|
|
fetch(API+'/trends/life_expectancy?country=SWE&from=2020&to=2023',H()).then(r=>r.json()).catch(()=>null),
|
|
fetch(API+'/trends/unemployment?country=SWE&from=2020&to=2023',H()).then(r=>r.json()).catch(()=>null),
|
|
fetch(API+'/freedom?country=SWE',H()).then(r=>r.json()).catch(()=>null),
|
|
fetch(API+'/gmi/all',H()).then(r=>r.json()).catch(()=>null),
|
|
fetch(API+'/decisions?sz=5',H()).then(r=>r.json()).catch(()=>null)
|
|
]);
|
|
|
|
const latest = (d) => d?.series?.slice(-1)[0];
|
|
const fmt = (v, dec=0) => v!=null ? (dec?v.toFixed(dec):Math.round(v).toLocaleString('sv-SE')) : '—';
|
|
|
|
const gdpV = latest(gdpD)?.value;
|
|
document.getElementById('gdp').textContent = gdpV ? '$'+Math.round(gdpV/1000)+'k' : '—';
|
|
document.getElementById('gdp').parentElement.classList.remove('loading-pulse');
|
|
|
|
const lifeV = latest(lifeD)?.value;
|
|
document.getElementById('life').textContent = lifeV ? lifeV.toFixed(1) : '—';
|
|
document.getElementById('life').parentElement.classList.remove('loading-pulse');
|
|
|
|
const unempV = latest(unempD)?.value;
|
|
document.getElementById('unemp').textContent = unempV ? unempV.toFixed(1)+'%' : '—';
|
|
document.getElementById('unemp').parentElement.classList.remove('loading-pulse');
|
|
|
|
if (demoD?.overall_democracy != null) {
|
|
document.getElementById('demo').textContent = demoD.overall_democracy;
|
|
document.getElementById('demo').parentElement.classList.remove('loading-pulse');
|
|
}
|
|
|
|
if (gmiD?.data) {
|
|
const nordics = ['SWE','NOR','DNK','FIN','ISL'];
|
|
const gmiList = document.getElementById('gmi-list');
|
|
gmiList.innerHTML = nordics.map(iso => {
|
|
const d = gmiD.data[iso];
|
|
if (!d) return '';
|
|
const score = d.overall;
|
|
const color = score >= 80 ? '#00ff88' : score >= 60 ? '#00d4ff' : '#ff6b35';
|
|
return '<div class="signal-item"><span class="signal-name">'+iso+'</span><span class="signal-score" style="color:'+color+'">'+score+'</span></div>';
|
|
}).join('');
|
|
}
|
|
|
|
if (decisionsD?.decisions) {
|
|
const dl = document.getElementById('decisions');
|
|
dl.innerHTML = decisionsD.decisions.slice(0,3).map(d =>
|
|
'<div class="signal-item"><span class="signal-name" style="max-width:240px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">'+d.title+'</span><span class="signal-score" style="color:#4a7090;font-size:.75em">'+d.date+'</span></div>'
|
|
).join('');
|
|
}
|
|
|
|
document.getElementById('updated').textContent = 'Uppdaterad ' + new Date().toLocaleTimeString('sv-SE') + ' · World Bank + Riksdagen';
|
|
} catch(e) { console.error(e); }
|
|
}
|
|
|
|
load();
|
|
setInterval(load, 300000); // refresh every 5min
|
|
</script>
|
|
</body></html>`);
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// LEGACY: Preserve existing routes (commune, compare, topics, kpi, query)
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
const COMMUNES = {'0180':'Stockholm','1480':'Göteborg','1280':'Malmö','1880':'Örebro','1860':'Uppsala','2481':'Umeå','0580':'Norrköping','1281':'Lund','1283':'Helsingborg','1980':'Västerås','1960':'Södertälje'};
|
|
const TOPICS = {
|
|
turism:{name:'Turism & besöksnäring',emoji:'🏨',kpis:['N03010']},
|
|
kommunalskatt:{name:'Kommunalskatt & ekonomi',emoji:'💰',kpis:['N07900']},
|
|
arbetsmarknad:{name:'Arbetsmarknad',emoji:'👷',kpis:['U29718']},
|
|
befolkning:{name:'Befolkning',emoji:'👥',kpis:['N01951']},
|
|
skola:{name:'Skola & utbildning',emoji:'🎓',kpis:['N15419','N15036']}
|
|
};
|
|
const extractVal = (d) => { try { return d?.values?.[0]?.values?.[0]?.value ?? null; } catch { return null; } };
|
|
|
|
router.get('/topics', (_, res) => res.json({ ok: true, topics: Object.entries(TOPICS).map(([key, t]) => ({ key, ...t })) }));
|
|
|
|
router.get('/commune/:id', async (req, res) => {
|
|
const { id } = req.params;
|
|
const name = COMMUNES[id] || `Kommun ${id}`;
|
|
const [ekono, arbete] = await Promise.all([
|
|
kolada(`data/kpi/N07900/municipality/${id}/year/2022`),
|
|
kolada(`data/kpi/N03010/municipality/${id}/year/2022`)
|
|
]);
|
|
res.json({ ok: true, commune: { id, name }, data: { kommunala_intakter_per_inv: extractVal(ekono), turism: extractVal(arbete) }, source: 'Kolada v3', ts: new Date().toISOString() });
|
|
});
|
|
|
|
router.get('/compare', async (req, res) => {
|
|
const ids = (req.query.ids || '0180,1480,1280,1880').split(',').slice(0, 6);
|
|
const kpi = req.query.kpi || 'N07900';
|
|
const year = req.query.year || '2022';
|
|
const results = await Promise.all(ids.map(async id => {
|
|
const d = await kolada(`data/kpi/${kpi}/municipality/${id}/year/${year}`);
|
|
return { id, name: COMMUNES[id] || id, value: extractVal(d) };
|
|
}));
|
|
results.sort((a, b) => (b.value || 0) - (a.value || 0));
|
|
res.json({ ok: true, kpi, year, comparison: results, source: 'Kolada v3', ts: new Date().toISOString() });
|
|
});
|
|
|
|
router.get('/kpi/:id', async (req, res) => {
|
|
const { id } = req.params;
|
|
const { year = '2022', municipality } = req.query;
|
|
const path = municipality ? `data/kpi/${id}/municipality/${municipality}/year/${year}` : `data/kpi/${id}/year/${year}`;
|
|
const data = await kolada(path);
|
|
res.json({ ok: true, kpi: id, year, municipality, data, source: 'Kolada v3', ts: new Date().toISOString() });
|
|
});
|
|
|
|
router.post('/query', async (req, res) => {
|
|
const { question, commune_id } = req.body;
|
|
if (!question) return res.status(400).json({ error: 'question required' });
|
|
const q = question.toLowerCase();
|
|
let topic = null, kpi = 'N07900';
|
|
if (q.match(/turism|gästnätter|besök/)) { topic = TOPICS.turism; kpi = 'N03010'; }
|
|
else if (q.match(/skatt|intäkt|ekonomi/)) { topic = TOPICS.kommunalskatt; kpi = 'N07900'; }
|
|
else if (q.match(/arbete|jobb|sysselsättning/)) { topic = TOPICS.arbetsmarknad; kpi = 'U29718'; }
|
|
else if (q.match(/befolkning|invånare/)) { topic = TOPICS.befolkning; kpi = 'N01951'; }
|
|
else if (q.match(/skola|utbildning|betyg/)) { topic = TOPICS.skola; kpi = 'N15419'; }
|
|
let data = null;
|
|
if (commune_id) data = await kolada(`data/kpi/${kpi}/municipality/${commune_id}/year/2022`);
|
|
res.json({ ok: true, question, topic: topic?.name, commune: commune_id ? (COMMUNES[commune_id] || commune_id) : null,
|
|
value: extractVal(data), kpi_used: kpi, source: 'Kolada v3', ts: new Date().toISOString() });
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// DB INIT — skapa tabeller om de saknas
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
async function initDB() {
|
|
const pool = getPool();
|
|
try {
|
|
await pool.query(`
|
|
CREATE TABLE IF NOT EXISTS dissg_subscriptions (
|
|
user_id TEXT PRIMARY KEY, tier TEXT NOT NULL DEFAULT 'free',
|
|
email TEXT, active BOOLEAN DEFAULT true,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ
|
|
);
|
|
CREATE TABLE IF NOT EXISTS dissg_audit_log (
|
|
id BIGSERIAL PRIMARY KEY, event TEXT NOT NULL,
|
|
data JSONB, ts TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
CREATE INDEX IF NOT EXISTS dissg_audit_event_idx ON dissg_audit_log(event);
|
|
CREATE INDEX IF NOT EXISTS dissg_audit_ts_idx ON dissg_audit_log(ts);
|
|
CREATE TABLE IF NOT EXISTS dissg_alerts (
|
|
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
|
country TEXT, indicator TEXT, threshold NUMERIC,
|
|
direction TEXT DEFAULT 'any', active BOOLEAN DEFAULT true,
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
`);
|
|
console.log('[DISSG] DB tables initialized');
|
|
} catch (e) {
|
|
console.warn('[DISSG] DB init warning:', e.message);
|
|
}
|
|
}
|
|
initDB();
|
|
|
|
export default router;
|