Files
boc/dissg-build/routes.mjs
T
Bernt bae705aa97 ARCHITECTURE: NFC roadmap, edge AI, audit logging
- 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
2026-06-29 16:24:48 +00:00

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} &nbsp; <strong>Year:</strong> ${year} &nbsp; <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;